diff --git a/docs/cli/release/changelog-render.md b/docs/cli/release/changelog-render.md index 61fc68e48..26511dbd9 100644 --- a/docs/cli/release/changelog-render.md +++ b/docs/cli/release/changelog-render.md @@ -1,6 +1,6 @@ # changelog render -Generate markdown files from changelog bundle files. +Generate markdown or asciidoc files from changelog bundle files. To create the bundle files, use [](/cli/release/changelog-bundle.md). @@ -25,14 +25,20 @@ docs-builder changelog render [options...] [-h|--help] : **Important**: Paths must be absolute or use environment variables. Tilde (`~`) expansion is not supported. `--output ` -: Optional: The output directory for rendered markdown files. +: Optional: The output directory for rendered files. : Defaults to current directory. `--title ` -: Optional: The title to use for section headers, directories, and anchors in output markdown files. +: 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. +`--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. + `--subsections` : Optional: Group entries by area in subsections. : Defaults to false. @@ -42,7 +48,7 @@ docs-builder changelog render [options...] [-h|--help] : 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 markdown output and a warning will be emitted. +: Entries with matching `feature-id` values will be commented out in the output and a warning will be emitted. `--config ` : Optional: Path to the changelog.yml configuration file. @@ -51,3 +57,28 @@ docs-builder changelog render [options...] [-h|--help] You can configure `render_blockers` in your `changelog.yml` configuration file to automatically block changelog entries from being rendered based on their products, areas, and/or types. For more information, refer to [](/contribute/changelog.md#render-blockers). + +## 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 + +### Asciidoc format + +When `--file-type asciidoc` is specified, the command generates a single asciidoc file with all sections: +- Security updates +- Bug fixes +- New features and enhancements +- Breaking changes +- Deprecations +- Known issues +- Documentation +- Regressions +- Other changes + +The asciidoc output uses attribute references for links (e.g., `{repo-pull}NUMBER[#NUMBER]`) and follows patterns similar to Elasticsearch release notes. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index d9057eba0..378c99972 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -97,8 +97,8 @@ Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/ma ### Render blockers [render-blockers] -You can optionally add `render_blockers` in your changelog configuration to block specific changelog entries from being rendered in markdown output files. -When you run the `docs-builder changelog render` command, changelog entries that match the specified products and areas/types will be commented out in the markdown output. +You can optionally add `render_blockers` in your changelog configuration to block specific changelog entries from being rendered in output files. +When you run the `docs-builder changelog render` command, changelog entries that match the specified products and areas/types will be commented out in the output (markdown or asciidoc). By default, the `docs-builder changelog render` command checks the following path: `docs/changelog.yml`. You can specify a different path with the `--config` command option. @@ -334,18 +334,19 @@ If you specify a file path with a different extension (not `.yml` or `.yaml`), t ## Create documentation [render-changelogs] -The `docs-builder changelog render` command creates markdown files from changelog bundles for documentation purposes. +The `docs-builder changelog render` command creates markdown or asciidoc files from changelog bundles for documentation purposes. For up-to-date details, use the `-h` command option: ```sh -Render bundled changelog(s) to markdown files +Render bundled changelog(s) to markdown or asciidoc files Options: --input 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). Paths must be absolute or use environment variables; tilde (~) expansion is not supported. [Required] - --output Optional: Output directory for rendered markdown files. Defaults to current directory [Default: null] - --title Optional: Title to use for section headers in output markdown files. Defaults to version from first bundle [Default: null] + --output Optional: Output directory for rendered files. Defaults to current directory [Default: null] + --title Optional: Title to use for section headers in output files. Defaults to version from first bundle [Default: null] --subsections Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false - --hide-features 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 markdown output. [Default: null] + --hide-features 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. [Default: null] + --file-type Optional: Output file type. Valid values: "markdown" or "asciidoc". Defaults to "markdown" [Default: markdown] --config Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' [Default: null] ``` @@ -379,7 +380,7 @@ docs-builder changelog render \ ``` 1. Provide information about the changelog bundle(s). The format for each bundle is `"|||"` using pipe (`|`) as delimiter. To merge multiple bundles, separate them with commas (`,`). Only the `` is required for each bundle. The `` is useful if the changelogs are not in the default directory and are not resolved within the bundle. The `` is necessary if your changelogs do not contain full URLs for the pull requests or issues. The `` can be `hide-links` or `keep-links` (default) to control whether PR/issue links are hidden for entries from private repositories. -2. The `--title` value is used for an output folder name and for section titles in the markdown files. If you omit `--title` and the first bundle contains a product `target` value, that value is used. Otherwise, if none of the bundles have product `target` fields, the title defaults to "unknown". +2. The `--title` value is used for an output folder name and for section titles in the output files. If you omit `--title` and the first bundle contains a product `target` value, that value is used. Otherwise, if none of the bundles have product `target` fields, the title defaults to "unknown". 3. By default the command creates the output files in the current directory. 4. By default the changelog areas are not displayed in the output. Add `--subsections` to group changelog details by their `areas`. For breaking changes that have a `subtype` value, the subsections will be grouped by subtype instead of area. @@ -408,3 +409,16 @@ To comment out the pull request and issue links, for example if they relate to a If you have changelogs with `feature-id` values and you want them to be omitted from the output, use the `--hide-features` option. For more information, refer to [](/cli/release/changelog-render.md). + +To create an asciidoc file instead of markdown files, add the `--file-type asciidoc` option: + +```sh +docs-builder changelog render \ + --input "./changelog-bundle.yaml,./changelogs,elasticsearch" \ + --title 9.2.2 \ + --output ./release-notes \ + --file-type asciidoc \ <1> + --subsections +``` + +1. Generate a single asciidoc file instead of multiple markdown files. diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs index c9b86586a..f2ad0fccf 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogRenderInput.cs @@ -5,7 +5,7 @@ namespace Elastic.Documentation.Services.Changelog; /// -/// Input data for rendering changelog bundle to markdown +/// Input data for rendering changelog bundle to markdown or asciidoc /// public class ChangelogRenderInput { @@ -15,5 +15,6 @@ public class ChangelogRenderInput public bool Subsections { get; set; } public string[]? HideFeatures { get; set; } public string? Config { get; set; } + public string FileType { get; set; } = "markdown"; } diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 04713496b..1d769dab9 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -1893,22 +1893,32 @@ Cancel ctx entryToHideLinks[entry] = hideLinks; } - // Render markdown files (use first repo found for section anchors, or default) + // Render files (use first repo found for section anchors, or default) var repoForAnchors = allResolvedEntries.Count > 0 ? allResolvedEntries[0].repo : defaultRepo; + var fileType = input.FileType ?? "markdown"; - // Render index.md (features, enhancements, bug fixes, security, docs, regression, other) - await RenderIndexMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); + if (string.Equals(fileType, "asciidoc", StringComparison.OrdinalIgnoreCase)) + { + // Render asciidoc file + await RenderAsciidoc(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); + _logger.LogInformation("Rendered changelog asciidoc file to {OutputDir}", outputDir); + } + else + { + // Render index.md (features, enhancements, bug fixes, security, docs, regression, other) + await RenderIndexMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); - // Render breaking-changes.md - await RenderBreakingChangesMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); + // Render breaking-changes.md + await RenderBreakingChangesMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); - // Render deprecations.md - await RenderDeprecationsMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); + // Render deprecations.md + await RenderDeprecationsMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); - // Render known-issues.md - await RenderKnownIssuesMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); + // Render known-issues.md + await RenderKnownIssuesMarkdown(collector, outputDir, title, titleSlug, repoForAnchors, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks, ctx); - _logger.LogInformation("Rendered changelog markdown files to {OutputDir}", outputDir); + _logger.LogInformation("Rendered changelog markdown files to {OutputDir}", outputDir); + } return true; } @@ -2803,5 +2813,562 @@ private static string FormatIssueLink(string issue, string repo, bool hidePrivat return link; } + + private static string FormatPrLinkAsciidoc(string pr, string repo, bool hidePrivateLinks) + { + // Extract PR number + var match = TrailingNumberRegex().Match(pr); + var prNumber = match.Success ? match.Value : pr; + + // Format as asciidoc link attribute reference + // Format: {repo-pull}PRNUMBER[#PRNUMBER] + // Convert repo name to attribute format (e.g., "elastic-agent" -> "agent-pull", "fleet-server" -> "fleet-server-pull") + var attributeName = ConvertRepoToAttributeName(repo, "pull"); + var link = $"{{{attributeName}}}{prNumber}[#{prNumber}]"; + + // Comment out link if hiding private links + if (hidePrivateLinks) + { + return $"// {link}"; + } + + return link; + } + + private static string FormatIssueLinkAsciidoc(string issue, string repo, bool hidePrivateLinks) + { + // Extract issue number + var match = TrailingNumberRegex().Match(issue); + var issueNumber = match.Success ? match.Value : issue; + + // Format as asciidoc link attribute reference + // Format: {repo-issue}ISSUENUMBER[#ISSUENUMBER] + // Convert repo name to attribute format (e.g., "elastic-agent" -> "agent-issue", "fleet-server" -> "fleet-server-issue") + var attributeName = ConvertRepoToAttributeName(repo, "issue"); + var link = $"{{{attributeName}}}{issueNumber}[#{issueNumber}]"; + + // Comment out link if hiding private links + if (hidePrivateLinks) + { + return $"// {link}"; + } + + return link; + } + + private static string ConvertRepoToAttributeName(string repo, string suffix) + { + // Convert repo name to attribute format + // Examples: + // "elastic-agent" -> "agent-pull" + // "fleet-server" -> "fleet-server-pull" + // "elastic-agent-libs" -> "agent-libs-pull" + // "elasticsearch" -> "es-pull" + // "kibana" -> "kibana-pull" + + if (string.IsNullOrWhiteSpace(repo)) + { + return $"repo-{suffix}"; + } + + // Handle common repo name patterns + if (repo.Equals("elasticsearch", StringComparison.OrdinalIgnoreCase)) + { + return $"es-{suffix}"; + } + + // Remove "elastic-" prefix if present + var normalized = repo; + if (normalized.StartsWith("elastic-", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring("elastic-".Length); + } + + // Return normalized name with suffix + return $"{normalized}-{suffix}"; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Parameters match interface pattern")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "StringBuilder methods return builder for chaining")] + private async Task RenderAsciidoc( + IDiagnosticsCollector collector, + string outputDir, + string title, + string titleSlug, + string repo, + List entries, + Dictionary> entriesByType, + bool subsections, + HashSet featureIdsToHide, + Dictionary? renderBlockers, + Dictionary> entryToBundleProducts, + Dictionary entryToRepo, + Dictionary entryToHideLinks, + Cancel ctx + ) + { + var sb = new StringBuilder(); + + // Add anchor + sb.AppendLine(CultureInfo.InvariantCulture, $"[[release-notes-{titleSlug}]]"); + sb.AppendLine(CultureInfo.InvariantCulture, $"== {title}"); + sb.AppendLine(); + + // Group entries by type + var security = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Security, []); + var bugFixes = entriesByType.GetValueOrDefault(ChangelogEntryTypes.BugFix, []); + var features = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Feature, []); + var enhancements = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Enhancement, []); + var breakingChanges = entriesByType.GetValueOrDefault(ChangelogEntryTypes.BreakingChange, []); + var deprecations = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Deprecation, []); + var knownIssues = entriesByType.GetValueOrDefault(ChangelogEntryTypes.KnownIssue, []); + var docs = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Docs, []); + var regressions = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Regression, []); + var other = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Other, []); + + // Render security updates + if (security.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[security-updates-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Security updates"); + sb.AppendLine(); + RenderEntriesByAreaAsciidoc(sb, security, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render bug fixes + if (bugFixes.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[bug-fixes-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Bug fixes"); + sb.AppendLine(); + RenderEntriesByAreaAsciidoc(sb, bugFixes, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render features and enhancements + if (features.Count > 0 || enhancements.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[features-enhancements-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== New features and enhancements"); + sb.AppendLine(); + var combined = features.Concat(enhancements).ToList(); + RenderEntriesByAreaAsciidoc(sb, combined, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render breaking changes + if (breakingChanges.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[breaking-changes-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Breaking changes"); + sb.AppendLine(); + RenderBreakingChangesAsciidoc(sb, breakingChanges, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render deprecations + if (deprecations.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[deprecations-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Deprecations"); + sb.AppendLine(); + RenderDeprecationsAsciidoc(sb, deprecations, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render known issues + if (knownIssues.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[known-issues-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Known issues"); + sb.AppendLine(); + RenderKnownIssuesAsciidoc(sb, knownIssues, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render documentation changes + if (docs.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[docs-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Documentation"); + sb.AppendLine(); + RenderEntriesByAreaAsciidoc(sb, docs, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render regressions + if (regressions.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[regressions-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Regressions"); + sb.AppendLine(); + RenderEntriesByAreaAsciidoc(sb, regressions, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Render other changes + if (other.Count > 0) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"[[other-{titleSlug}]]"); + sb.AppendLine("[float]"); + sb.AppendLine("=== Other changes"); + sb.AppendLine(); + RenderEntriesByAreaAsciidoc(sb, other, repo, subsections, featureIdsToHide, renderBlockers, entryToBundleProducts, entryToRepo, entryToHideLinks); + sb.AppendLine(); + } + + // Write the asciidoc file + var asciidocPath = _fileSystem.Path.Combine(outputDir, $"{titleSlug}.asciidoc"); + var asciidocDir = _fileSystem.Path.GetDirectoryName(asciidocPath); + if (!string.IsNullOrWhiteSpace(asciidocDir) && !_fileSystem.Directory.Exists(asciidocDir)) + { + _ = _fileSystem.Directory.CreateDirectory(asciidocDir); + } + + await _fileSystem.File.WriteAllTextAsync(asciidocPath, sb.ToString(), ctx); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "StringBuilder methods return builder for chaining")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Parameter matches interface pattern for consistency")] + private void RenderEntriesByAreaAsciidoc(StringBuilder sb, List entries, string repo, bool subsections, HashSet featureIdsToHide, Dictionary? renderBlockers, Dictionary> entryToBundleProducts, Dictionary entryToRepo, Dictionary entryToHideLinks) + { + var groupedByArea = entries.GroupBy(e => GetComponent(e)).ToList(); + foreach (var areaGroup in groupedByArea) + { + var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; + + // Format component name (capitalize first letter, replace hyphens with spaces) + var formattedComponent = FormatAreaHeader(componentName); + + sb.AppendLine(CultureInfo.InvariantCulture, $"{formattedComponent}::"); + sb.AppendLine(); + + foreach (var entry in areaGroup) + { + var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || + ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out _); + + if (shouldHide) + { + sb.AppendLine("// "); + } + + sb.Append("* "); + sb.Append(Beautify(entry.Title)); + + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + var hasIssues = entry.Issues != null && entry.Issues.Count > 0; + + if (hasPr || hasIssues) + { + var entryRepo = entryToRepo.GetValueOrDefault(entry, repo); + var hideLinks = entryToHideLinks.GetValueOrDefault(entry, false); + sb.Append(' '); + if (hasPr) + { + sb.Append(FormatPrLinkAsciidoc(entry.Pr!, entryRepo, hideLinks)); + sb.Append(' '); + } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.Append(FormatIssueLinkAsciidoc(issue, entryRepo, hideLinks)); + sb.Append(' '); + } + } + } + + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + sb.AppendLine(); + var indented = Indent(entry.Description); + if (shouldHide) + { + var indentedLines = indented.Split('\n'); + foreach (var line in indentedLines) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"// {line}"); + } + } + else + { + sb.AppendLine(indented); + } + } + + sb.AppendLine(); + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "StringBuilder methods return builder for chaining")] + private void RenderBreakingChangesAsciidoc(StringBuilder sb, List breakingChanges, string repo, bool subsections, HashSet featureIdsToHide, Dictionary? renderBlockers, Dictionary> entryToBundleProducts, Dictionary entryToRepo, Dictionary entryToHideLinks) + { + // Group by subtype if subsections is enabled, otherwise group by area + var groupedEntries = subsections + ? breakingChanges.GroupBy(e => string.IsNullOrWhiteSpace(e.Subtype) ? string.Empty : e.Subtype).ToList() + : breakingChanges.GroupBy(e => GetComponent(e)).ToList(); + + foreach (var group in groupedEntries) + { + if (subsections && !string.IsNullOrWhiteSpace(group.Key)) + { + var header = FormatSubtypeHeader(group.Key); + sb.AppendLine(CultureInfo.InvariantCulture, $"**{header}**"); + sb.AppendLine(); + } + + foreach (var entry in group) + { + var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || + ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out _); + + if (shouldHide) + { + sb.AppendLine("// "); + } + + sb.Append("* "); + sb.Append(Beautify(entry.Title)); + + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + var hasIssues = entry.Issues != null && entry.Issues.Count > 0; + + if (hasPr || hasIssues) + { + var entryRepo = entryToRepo.GetValueOrDefault(entry, repo); + var hideLinks = entryToHideLinks.GetValueOrDefault(entry, false); + sb.Append(' '); + if (hasPr) + { + sb.Append(FormatPrLinkAsciidoc(entry.Pr!, entryRepo, hideLinks)); + sb.Append(' '); + } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.Append(FormatIssueLinkAsciidoc(issue, entryRepo, hideLinks)); + sb.Append(' '); + } + } + } + + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + sb.AppendLine(); + var indented = Indent(entry.Description); + if (shouldHide) + { + var indentedLines = indented.Split('\n'); + foreach (var line in indentedLines) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"// {line}"); + } + } + else + { + sb.AppendLine(indented); + } + } + + if (!string.IsNullOrWhiteSpace(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(); + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "StringBuilder methods return builder for chaining")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Parameter matches interface pattern for consistency")] + private void RenderDeprecationsAsciidoc(StringBuilder sb, List deprecations, string repo, bool subsections, HashSet featureIdsToHide, Dictionary? renderBlockers, Dictionary> entryToBundleProducts, Dictionary entryToRepo, Dictionary entryToHideLinks) + { + var groupedByArea = deprecations.GroupBy(e => GetComponent(e)).ToList(); + foreach (var areaGroup in groupedByArea) + { + var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; + var formattedComponent = FormatAreaHeader(componentName); + + sb.AppendLine(CultureInfo.InvariantCulture, $"{formattedComponent}::"); + sb.AppendLine(); + + foreach (var entry in areaGroup) + { + var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || + ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out _); + + if (shouldHide) + { + sb.AppendLine("// "); + } + + sb.Append("* "); + sb.Append(Beautify(entry.Title)); + + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + var hasIssues = entry.Issues != null && entry.Issues.Count > 0; + + if (hasPr || hasIssues) + { + var entryRepo = entryToRepo.GetValueOrDefault(entry, repo); + var hideLinks = entryToHideLinks.GetValueOrDefault(entry, false); + sb.Append(' '); + if (hasPr) + { + sb.Append(FormatPrLinkAsciidoc(entry.Pr!, entryRepo, hideLinks)); + sb.Append(' '); + } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.Append(FormatIssueLinkAsciidoc(issue, entryRepo, hideLinks)); + sb.Append(' '); + } + } + } + + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + sb.AppendLine(); + var indented = Indent(entry.Description); + if (shouldHide) + { + var indentedLines = indented.Split('\n'); + foreach (var line in indentedLines) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"// {line}"); + } + } + else + { + sb.AppendLine(indented); + } + } + + if (!string.IsNullOrWhiteSpace(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(); + } + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "StringBuilder methods return builder for chaining")] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Parameter matches interface pattern for consistency")] + private void RenderKnownIssuesAsciidoc(StringBuilder sb, List knownIssues, string repo, bool subsections, HashSet featureIdsToHide, Dictionary? renderBlockers, Dictionary> entryToBundleProducts, Dictionary entryToRepo, Dictionary entryToHideLinks) + { + var groupedByArea = knownIssues.GroupBy(e => GetComponent(e)).ToList(); + foreach (var areaGroup in groupedByArea) + { + var componentName = !string.IsNullOrWhiteSpace(areaGroup.Key) ? areaGroup.Key : "General"; + var formattedComponent = FormatAreaHeader(componentName); + + sb.AppendLine(CultureInfo.InvariantCulture, $"{formattedComponent}::"); + sb.AppendLine(); + + foreach (var entry in areaGroup) + { + var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); + var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || + ShouldBlockEntry(entry, bundleProductIds, renderBlockers, out _); + + if (shouldHide) + { + sb.AppendLine("// "); + } + + sb.Append("* "); + sb.Append(Beautify(entry.Title)); + + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + var hasIssues = entry.Issues != null && entry.Issues.Count > 0; + + if (hasPr || hasIssues) + { + var entryRepo = entryToRepo.GetValueOrDefault(entry, repo); + var hideLinks = entryToHideLinks.GetValueOrDefault(entry, false); + sb.Append(' '); + if (hasPr) + { + sb.Append(FormatPrLinkAsciidoc(entry.Pr!, entryRepo, hideLinks)); + sb.Append(' '); + } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.Append(FormatIssueLinkAsciidoc(issue, entryRepo, hideLinks)); + sb.Append(' '); + } + } + } + + if (!string.IsNullOrWhiteSpace(entry.Description)) + { + sb.AppendLine(); + var indented = Indent(entry.Description); + if (shouldHide) + { + var indentedLines = indented.Split('\n'); + foreach (var line in indentedLines) + { + sb.AppendLine(CultureInfo.InvariantCulture, $"// {line}"); + } + } + else + { + sb.AppendLine(indented); + } + } + + if (!string.IsNullOrWhiteSpace(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(); + } + } + } } diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 3f0d98efa..81c355b09 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -25,7 +25,7 @@ IConfigurationContext configurationContext [Command("")] public Task Default() { - collector.EmitError(string.Empty, "Please specify a subcommand. Available subcommands:\n - 'changelog add': Create a new changelog from command-line input\n - 'changelog bundle': Create a consolidated list of changelog files\n - 'changelog render': Render a bundled changelog to markdown files\n\nRun 'changelog add --help', 'changelog bundle --help', or 'changelog render --help' for usage information."); + collector.EmitError(string.Empty, "Please specify a subcommand. Available subcommands:\n - 'changelog add': Create a new changelog from command-line input\n - 'changelog bundle': Create a consolidated list of changelog files\n - 'changelog render': Render a bundled changelog to markdown or asciidoc files\n\nRun 'changelog add --help', 'changelog bundle --help', or 'changelog render --help' for usage information."); return Task.FromResult(1); } @@ -284,13 +284,15 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s } /// - /// Render bundled changelog(s) to markdown files + /// Render bundled changelog(s) to markdown or asciidoc files /// /// 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). Paths must be absolute or use environment variables; tilde (~) expansion is not supported. - /// Optional: Output directory for rendered markdown files. Defaults to current directory - /// Optional: Title to use for section headers in output markdown files. Defaults to version from first bundle + /// Optional: Output directory for rendered files. Defaults to current directory + /// Optional: Title to use for section headers in output files. Defaults to version from first bundle /// Optional: Group entries by area/component in subsections. For breaking changes with a subtype, groups by subtype instead of area. Defaults to false - /// 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 markdown output. + /// 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 file type. Valid values: "markdown" or "asciidoc". Defaults to "markdown" + /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// [Command("render")] public async Task Render( @@ -299,6 +301,7 @@ public async Task Render( string? title = null, bool subsections = false, string[]? hideFeatures = null, + string? fileType = "markdown", string? config = null, Cancel ctx = default ) @@ -329,6 +332,14 @@ public async Task Render( } } + // Validate file-type + if (!string.Equals(fileType, "markdown", StringComparison.OrdinalIgnoreCase) && + !string.Equals(fileType, "asciidoc", StringComparison.OrdinalIgnoreCase)) + { + collector.EmitError(string.Empty, $"Invalid file-type '{fileType}'. Valid values are 'markdown' or 'asciidoc'."); + return 1; + } + // Parse each --input value into BundleInput objects var bundles = BundleInputParser.ParseAll(input); @@ -339,6 +350,7 @@ public async Task Render( Title = title, Subsections = subsections, HideFeatures = allFeatureIds.Count > 0 ? allFeatureIds.ToArray() : null, + FileType = fileType ?? "markdown", Config = config }; diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index c5e146d34..8494b7619 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -4479,6 +4479,204 @@ public async Task RenderChangelogs_WithUnhandledType_EmitsWarning() } } + [Fact] + public async Task RenderChangelogs_WithAsciidocFileType_CreatesSingleAsciidocFile() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog file + var changelog1 = """ + title: Test feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + description: This is a test feature + """; + + var changelogFile = fileSystem.Path.Combine(changelogDir, "1755268130-test-feature.yaml"); + await fileSystem.File.WriteAllTextAsync(changelogFile, changelog1, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(bundleFile)!); + + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-test-feature.yaml + checksum: {ComputeSha1(changelog1)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + FileType = "asciidoc" + }; + + // Act + var result = await service.RenderChangelogs(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + // Verify a single .asciidoc file is created (not multiple files like markdown) + var asciidocFiles = fileSystem.Directory.GetFiles(outputDir, "*.asciidoc", SearchOption.AllDirectories); + asciidocFiles.Should().HaveCount(1, "asciidoc render should create a single file"); + + var asciidocFile = asciidocFiles[0]; + var asciidocContent = await fileSystem.File.ReadAllTextAsync(asciidocFile, TestContext.Current.CancellationToken); + + // Verify valid asciidoc format elements + asciidocContent.Should().Contain("[[release-notes-", "should contain anchor"); + asciidocContent.Should().Contain("== 9.2.0", "should contain section header"); + asciidocContent.Should().Contain("[[features-enhancements-", "should contain features section anchor"); + asciidocContent.Should().Contain("=== New features and enhancements", "should contain features section header"); + asciidocContent.Should().Contain("* Test feature", "should contain changelog entry"); + asciidocContent.Should().Contain("This is a test feature", "should contain description"); + + // Verify no markdown files are created + var markdownFiles = fileSystem.Directory.GetFiles(outputDir, "*.md", SearchOption.AllDirectories); + markdownFiles.Should().BeEmpty("asciidoc render should not create markdown files"); + } + + [Fact] + public async Task RenderChangelogs_WithAsciidocFileType_ValidatesAsciidocFormat() + { + // Arrange + var service = new ChangelogService(_loggerFactory, _configurationContext, null); + var fileSystem = new FileSystem(); + var changelogDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(changelogDir); + + // Create test changelog files with different types + var featureChangelog = """ + title: New search feature + type: feature + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/100 + description: Added new search capabilities + """; + + var bugFixChangelog = """ + title: Fixed search bug + type: bug-fix + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/200 + description: Fixed a critical search issue + """; + + var breakingChangeChangelog = """ + title: Breaking API change + type: breaking-change + subtype: api + products: + - product: elasticsearch + target: 9.2.0 + pr: https://github.com/elastic/elasticsearch/pull/300 + description: Changed API endpoint structure + impact: Users need to update their API calls + action: Update API client libraries + """; + + var featureFile = fileSystem.Path.Combine(changelogDir, "1755268130-feature.yaml"); + var bugFixFile = fileSystem.Path.Combine(changelogDir, "1755268140-bugfix.yaml"); + var breakingFile = fileSystem.Path.Combine(changelogDir, "1755268150-breaking.yaml"); + await fileSystem.File.WriteAllTextAsync(featureFile, featureChangelog, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(bugFixFile, bugFixChangelog, TestContext.Current.CancellationToken); + await fileSystem.File.WriteAllTextAsync(breakingFile, breakingChangeChangelog, TestContext.Current.CancellationToken); + + // Create bundle file + var bundleFile = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString(), "bundle.yaml"); + fileSystem.Directory.CreateDirectory(fileSystem.Path.GetDirectoryName(bundleFile)!); + + var bundleContent = $""" + products: + - product: elasticsearch + target: 9.2.0 + entries: + - file: + name: 1755268130-feature.yaml + checksum: {ComputeSha1(featureChangelog)} + - file: + name: 1755268140-bugfix.yaml + checksum: {ComputeSha1(bugFixChangelog)} + - file: + name: 1755268150-breaking.yaml + checksum: {ComputeSha1(breakingChangeChangelog)} + """; + await fileSystem.File.WriteAllTextAsync(bundleFile, bundleContent, TestContext.Current.CancellationToken); + + var outputDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + + var input = new ChangelogRenderInput + { + Bundles = [new BundleInput { BundleFile = bundleFile, Directory = changelogDir }], + Output = outputDir, + Title = "9.2.0", + FileType = "asciidoc" + }; + + // 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); + + // Verify asciidoc structure + asciidocContent.Should().Contain("[[release-notes-9.2.0]]", "should contain main anchor"); + asciidocContent.Should().Contain("== 9.2.0", "should contain main header"); + + // Verify sections are present with proper asciidoc format + asciidocContent.Should().Contain("[[bug-fixes-9.2.0]]", "should contain bug fixes anchor"); + asciidocContent.Should().Contain("[float]", "should contain float attribute"); + asciidocContent.Should().Contain("=== Bug fixes", "should contain bug fixes header"); + + asciidocContent.Should().Contain("[[features-enhancements-9.2.0]]", "should contain features anchor"); + asciidocContent.Should().Contain("=== New features and enhancements", "should contain features header"); + + asciidocContent.Should().Contain("[[breaking-changes-9.2.0]]", "should contain breaking changes anchor"); + asciidocContent.Should().Contain("=== Breaking changes", "should contain breaking changes header"); + + // Verify entries are formatted correctly + asciidocContent.Should().Contain("* New search feature", "should contain feature entry"); + asciidocContent.Should().Contain("* Fixed search bug", "should contain bug fix entry"); + asciidocContent.Should().Contain("* Breaking API change", "should contain breaking change entry"); + + // Verify asciidoc list format (entries should start with *) + var lines = asciidocContent.Split('\n'); + var entryLines = lines.Where(l => l.TrimStart().StartsWith("* ", StringComparison.Ordinal) && !l.TrimStart().StartsWith("* *", StringComparison.Ordinal)).ToList(); + entryLines.Should().HaveCountGreaterThanOrEqualTo(3, "should have at least 3 changelog entries"); + + // Verify no invalid markdown syntax (like ##) is present + asciidocContent.Should().NotContain("##", "should not contain markdown headers"); + asciidocContent.Should().NotContain("###", "should not contain markdown headers"); + } + private static string ComputeSha1(string content) { var bytes = System.Text.Encoding.UTF8.GetBytes(content);