diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index b4d874185..790ef927e 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -303,7 +303,7 @@ Options: --input > Required: Bundle input(s) in format "bundle-file-path, changelog-file-path, repo". Can be specified multiple times. Only bundle-file-path is required. [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] - --subsections Optional: Group entries by area/component in subsections. Defaults to false + --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-private-links Optional: Hide private links by commenting them out in the markdown output. 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] --config Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' [Default: null] @@ -341,7 +341,7 @@ docs-builder changelog render \ 1. Provide information about the changelog bundle. The format is `", , "`. Only the `` is required. 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. You can specify `--input` multiple times to merge multiple bundles. 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". 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`. +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. For example, the `index.md` output file contains information derived from the changelogs: diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index dd541b6af..f3b009e71 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -36,6 +36,9 @@ private static class ChangelogEntryTypes public const string BreakingChange = "breaking-change"; public const string Deprecation = "deprecation"; public const string KnownIssue = "known-issue"; + public const string Docs = "docs"; + public const string Regression = "regression"; + public const string Other = "other"; } public async Task CreateChangelog( @@ -1770,6 +1773,34 @@ Cancel ctx } } + // Check for unhandled changelog types + var handledTypes = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ChangelogEntryTypes.Feature, + ChangelogEntryTypes.Enhancement, + ChangelogEntryTypes.Security, + ChangelogEntryTypes.BugFix, + ChangelogEntryTypes.BreakingChange, + ChangelogEntryTypes.Deprecation, + ChangelogEntryTypes.KnownIssue, + ChangelogEntryTypes.Docs, + ChangelogEntryTypes.Regression, + ChangelogEntryTypes.Other + }; + + var availableTypes = config.AvailableTypes ?? ChangelogConfiguration.Default.AvailableTypes; + var availableTypesSet = new HashSet(availableTypes, StringComparer.OrdinalIgnoreCase); + + foreach (var entryType in entriesByType.Keys) + { + // Only warn if the type is valid according to config but not handled in rendering + if (availableTypesSet.Contains(entryType) && !handledTypes.Contains(entryType)) + { + var entryCount = entriesByType[entryType].Count; + collector.EmitWarning(string.Empty, $"Changelog type '{entryType}' is valid according to configuration but is not handled in rendering output. {entryCount} entry/entries of this type will not be included in the generated markdown files."); + } + } + // Create mapping from entries to their bundle product IDs for render_blockers checking // Use a custom comparer for reference equality since entries are objects var entryToBundleProducts = new Dictionary>(); @@ -1781,7 +1812,7 @@ Cancel ctx // Render markdown files (use first repo found, or default) var repoForRendering = allResolvedEntries.Count > 0 ? allResolvedEntries[0].repo : defaultRepo; - // Render index.md (features, enhancements, bug fixes, security) + // Render index.md (features, enhancements, bug fixes, security, docs, regression, other) await RenderIndexMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts, ctx); // Render breaking-changes.md @@ -1790,6 +1821,9 @@ Cancel ctx // Render deprecations.md await RenderDeprecationsMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts, ctx); + // Render known-issues.md + await RenderKnownIssuesMarkdown(collector, outputDir, title, titleSlug, repoForRendering, allResolvedEntries.Select(e => e.entry).ToList(), entriesByType, input.Subsections, input.HidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts, ctx); + _logger.LogInformation("Rendered changelog markdown files to {OutputDir}", outputDir); return true; @@ -1837,11 +1871,9 @@ Cancel ctx var enhancements = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Enhancement, []); var security = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Security, []); var bugFixes = entriesByType.GetValueOrDefault(ChangelogEntryTypes.BugFix, []); - - if (features.Count == 0 && enhancements.Count == 0 && security.Count == 0 && bugFixes.Count == 0) - { - // Still create file with "no changes" message - } + var docs = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Docs, []); + var regressions = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Regression, []); + var other = entriesByType.GetValueOrDefault(ChangelogEntryTypes.Other, []); var hasBreakingChanges = entriesByType.ContainsKey(ChangelogEntryTypes.BreakingChange); var hasDeprecations = entriesByType.ContainsKey(ChangelogEntryTypes.Deprecation); @@ -1850,7 +1882,7 @@ Cancel ctx var otherLinks = new List(); if (hasKnownIssues) { - otherLinks.Add("[Known issues](/release-notes/known-issues.md)"); + otherLinks.Add($"[Known issues](/release-notes/known-issues.md#{repo}-{titleSlug}-known-issues)"); } if (hasBreakingChanges) { @@ -1871,7 +1903,9 @@ Cancel ctx sb.AppendLine(); } - if (features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || bugFixes.Count > 0) + var hasAnyEntries = features.Count > 0 || enhancements.Count > 0 || security.Count > 0 || bugFixes.Count > 0 || docs.Count > 0 || regressions.Count > 0 || other.Count > 0; + + if (hasAnyEntries) { if (features.Count > 0 || enhancements.Count > 0) { @@ -1887,6 +1921,27 @@ Cancel ctx var combined = security.Concat(bugFixes).ToList(); RenderEntriesByArea(sb, combined, repo, subsections, hidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts); } + + if (docs.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $"### Documentation [{repo}-{titleSlug}-docs]"); + RenderEntriesByArea(sb, docs, repo, subsections, hidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts); + } + + if (regressions.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $"### Regressions [{repo}-{titleSlug}-regressions]"); + RenderEntriesByArea(sb, regressions, repo, subsections, hidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts); + } + + if (other.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $"### Other changes [{repo}-{titleSlug}-other]"); + RenderEntriesByArea(sb, other, repo, subsections, hidePrivateLinks, featureIdsToHide, renderBlockers, entryToBundleProducts); + } } else { @@ -1928,17 +1983,21 @@ Cancel ctx if (breakingChanges.Count > 0) { - var groupedByArea = breakingChanges.GroupBy(e => GetComponent(e)).ToList(); - foreach (var areaGroup in groupedByArea) + // 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(areaGroup.Key)) + if (subsections && !string.IsNullOrWhiteSpace(group.Key)) { - var header = FormatAreaHeader(areaGroup.Key); + var header = FormatSubtypeHeader(group.Key); sb.AppendLine(); sb.AppendLine(CultureInfo.InvariantCulture, $"**{header}**"); } - foreach (var entry in areaGroup) + foreach (var entry in group) { var bundleProductIds = entryToBundleProducts.GetValueOrDefault(entry, new HashSet(StringComparer.OrdinalIgnoreCase)); var shouldHide = (!string.IsNullOrWhiteSpace(entry.FeatureId) && featureIdsToHide.Contains(entry.FeatureId)) || @@ -1952,40 +2011,45 @@ Cancel ctx sb.AppendLine(CultureInfo.InvariantCulture, $"::::{{dropdown}} {Beautify(entry.Title)}"); sb.AppendLine(entry.Description ?? "% Describe the functionality that changed"); sb.AppendLine(); - if (hidePrivateLinks) + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + var hasIssues = entry.Issues != null && entry.Issues.Count > 0; + if (hasPr || hasIssues) { - // When hiding private links, put them on separate lines as comments - if (!string.IsNullOrWhiteSpace(entry.Pr)) - { - sb.AppendLine(FormatPrLink(entry.Pr, repo, hidePrivateLinks)); - } - if (entry.Issues != null && entry.Issues.Count > 0) + if (hidePrivateLinks) { - foreach (var issue in entry.Issues) + // When hiding private links, put them on separate lines as comments + if (hasPr) { - sb.AppendLine(FormatIssueLink(issue, repo, hidePrivateLinks)); + sb.AppendLine(FormatPrLink(entry.Pr!, repo, hidePrivateLinks)); } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.AppendLine(FormatIssueLink(issue, repo, hidePrivateLinks)); + } + } + sb.AppendLine("For more information, check the pull request or issue above."); } - sb.AppendLine("For more information, check the pull request or issue above."); - } - else - { - sb.Append("For more information, check "); - if (!string.IsNullOrWhiteSpace(entry.Pr)) - { - sb.Append(FormatPrLink(entry.Pr, repo, hidePrivateLinks)); - } - if (entry.Issues != null && entry.Issues.Count > 0) + else { - foreach (var issue in entry.Issues) + sb.Append("For more information, check "); + if (hasPr) { - sb.Append(' '); - sb.Append(FormatIssueLink(issue, repo, hidePrivateLinks)); + sb.Append(FormatPrLink(entry.Pr!, repo, hidePrivateLinks)); } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.Append(' '); + sb.Append(FormatIssueLink(issue, repo, hidePrivateLinks)); + } + } + sb.AppendLine("."); } - sb.AppendLine("."); + sb.AppendLine(); } - sb.AppendLine(); if (!string.IsNullOrWhiteSpace(entry.Impact)) { @@ -2079,40 +2143,177 @@ Cancel ctx sb.AppendLine(CultureInfo.InvariantCulture, $"::::{{dropdown}} {Beautify(entry.Title)}"); sb.AppendLine(entry.Description ?? "% Describe the functionality that was deprecated"); sb.AppendLine(); - if (hidePrivateLinks) + var hasPr = !string.IsNullOrWhiteSpace(entry.Pr); + var hasIssues = entry.Issues != null && entry.Issues.Count > 0; + if (hasPr || hasIssues) { - // When hiding private links, put them on separate lines as comments - if (!string.IsNullOrWhiteSpace(entry.Pr)) + if (hidePrivateLinks) { - sb.AppendLine(FormatPrLink(entry.Pr, repo, hidePrivateLinks)); + // When hiding private links, put them on separate lines as comments + if (hasPr) + { + sb.AppendLine(FormatPrLink(entry.Pr!, repo, hidePrivateLinks)); + } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.AppendLine(FormatIssueLink(issue, repo, hidePrivateLinks)); + } + } + sb.AppendLine("For more information, check the pull request or issue above."); } - if (entry.Issues != null && entry.Issues.Count > 0) + else { - foreach (var issue in entry.Issues) + sb.Append("For more information, check "); + if (hasPr) { - sb.AppendLine(FormatIssueLink(issue, repo, hidePrivateLinks)); + sb.Append(FormatPrLink(entry.Pr!, repo, hidePrivateLinks)); } + if (hasIssues) + { + foreach (var issue in entry.Issues!) + { + sb.Append(' '); + sb.Append(FormatIssueLink(issue, repo, hidePrivateLinks)); + } + } + sb.AppendLine("."); } - sb.AppendLine("For more information, check the pull request or issue above."); + sb.AppendLine(); + } + + if (!string.IsNullOrWhiteSpace(entry.Impact)) + { + sb.AppendLine("**Impact**
" + entry.Impact); } else { - sb.Append("For more information, check "); - if (!string.IsNullOrWhiteSpace(entry.Pr)) + sb.AppendLine("% **Impact**
_Add a description of the impact_"); + } + + sb.AppendLine(); + + if (!string.IsNullOrWhiteSpace(entry.Action)) + { + sb.AppendLine("**Action**
" + entry.Action); + } + else + { + sb.AppendLine("% **Action**
_Add a description of the what action to take_"); + } + + sb.AppendLine("::::"); + if (shouldHide) + { + sb.AppendLine("-->"); + } + } + } + } + else + { + sb.AppendLine("_No deprecations._"); + } + + var deprecationsPath = _fileSystem.Path.Combine(outputDir, titleSlug, "deprecations.md"); + var deprecationsDir = _fileSystem.Path.GetDirectoryName(deprecationsPath); + if (!string.IsNullOrWhiteSpace(deprecationsDir) && !_fileSystem.Directory.Exists(deprecationsDir)) + { + _ = _fileSystem.Directory.CreateDirectory(deprecationsDir); + } + + await _fileSystem.File.WriteAllTextAsync(deprecationsPath, sb.ToString(), ctx); + } + + [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 RenderKnownIssuesMarkdown( + IDiagnosticsCollector collector, + string outputDir, + string title, + string titleSlug, + string repo, + List entries, + Dictionary> entriesByType, + bool subsections, + bool hidePrivateLinks, + HashSet featureIdsToHide, + Dictionary? renderBlockers, + Dictionary> entryToBundleProducts, + Cancel ctx + ) + { + var knownIssues = entriesByType.GetValueOrDefault(ChangelogEntryTypes.KnownIssue, []); + + var sb = new StringBuilder(); + sb.AppendLine(CultureInfo.InvariantCulture, $"## {title} [{repo}-{titleSlug}-known-issues]"); + + if (knownIssues.Count > 0) + { + var groupedByArea = knownIssues.GroupBy(e => GetComponent(e)).ToList(); + foreach (var areaGroup in groupedByArea) + { + if (subsections && !string.IsNullOrWhiteSpace(areaGroup.Key)) + { + var header = FormatAreaHeader(areaGroup.Key); + sb.AppendLine(); + sb.AppendLine(CultureInfo.InvariantCulture, $"**{header}**"); + } + + 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 _); + + sb.AppendLine(); + if (shouldHide) + { + sb.AppendLine("