From 32c80e27ea8184481cab93fb4b340373f802ad28 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 1 Dec 2025 18:40:27 -0800 Subject: [PATCH 01/21] Add release-note create command --- config/release-notes.yml.example | 92 +++++++ .../Elastic.Documentation.Services.csproj | 7 + .../ReleaseNotes/ReleaseNotesConfiguration.cs | 80 ++++++ .../ReleaseNotes/ReleaseNotesData.cs | 35 +++ .../ReleaseNotes/ReleaseNotesInput.cs | 29 ++ .../ReleaseNotesYamlStaticContext.cs | 15 + .../ReleaseNotesService.cs | 260 ++++++++++++++++++ .../Commands/ReleaseNotesCommand.cs | 101 +++++++ src/tooling/docs-builder/Program.cs | 1 + 9 files changed, 620 insertions(+) create mode 100644 config/release-notes.yml.example create mode 100644 src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs create mode 100644 src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs create mode 100644 src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs create mode 100644 src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs create mode 100644 src/services/Elastic.Documentation.Services/ReleaseNotesService.cs create mode 100644 src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs diff --git a/config/release-notes.yml.example b/config/release-notes.yml.example new file mode 100644 index 000000000..108d2f9d9 --- /dev/null +++ b/config/release-notes.yml.example @@ -0,0 +1,92 @@ +# Release Notes Configuration +# This file configures how PR labels are mapped to YAML fields in release notes fragments +# Place this file as `release-notes.yml` in the `config/` directory + +# Available types for release notes +available_types: + - feature + - enhancement + - bug-fix + - known-issue + - breaking-change + - deprecation + - docs + - regression + - security + - other + +# Available subtypes for breaking changes +available_subtypes: + - api + - behavioral + - configuration + - dependency + - subscription + - plugin + - security + - other + +# Available lifecycle values +available_lifecycles: + - preview + - beta + - ga + +# Label mappings - maps GitHub PR labels to YAML field values +label_mappings: + # Maps PR labels to "type" field + type: + bug: bug-fix + enhancement: enhancement + feature: feature + breaking: breaking-change + deprecation: deprecation + security: security + docs: docs + regression: regression + known-issue: known-issue + + # Maps PR labels to "subtype" field (for breaking changes) + subtype: + breaking:api: api + breaking:behavioral: behavioral + breaking:config: configuration + breaking:dependency: dependency + breaking:subscription: subscription + breaking:plugin: plugin + breaking:security: security + + # Maps PR labels to "product" field + # Add mappings for your products, e.g.: + product: + product:elasticsearch: elasticsearch + product:kibana: kibana + product:elasticsearch-client: elasticsearch-client + product:apm: apm + product:beats: beats + product:elastic-agent: elastic-agent + product:fleet: fleet + product:cloud-hosted: cloud-hosted + product:cloud-enterprise: cloud-enterprise + # Add more product mappings as needed + + # Maps PR labels to "area" field + # Areas vary by product - add mappings for your specific areas + area: + area:search: search + area:security: security + area:ml: machine-learning + area:observability: observability + area:index-management: index-management + # Add more area mappings as needed + + # Maps PR labels to "lifecycle" field + lifecycle: + lifecycle:preview: preview + lifecycle:beta: beta + lifecycle:ga: ga + + # Maps PR labels to "highlight" flag + highlight: + highlight: true + release-highlight: true diff --git a/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj b/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj index bf808d354..0494a04d1 100644 --- a/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj +++ b/src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj @@ -6,8 +6,15 @@ enable + + + + + + + diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs new file mode 100644 index 000000000..25252ccfc --- /dev/null +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs @@ -0,0 +1,80 @@ +// 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 + +namespace Elastic.Documentation.Services.ReleaseNotes; + +/// +/// Configuration for release notes generation, including label mappings +/// +public class ReleaseNotesConfiguration +{ + public LabelMappings LabelMappings { get; set; } = new(); + public List AvailableTypes { get; set; } = + [ + "feature", + "enhancement", + "bug-fix", + "known-issue", + "breaking-change", + "deprecation", + "docs", + "regression", + "security", + "other" + ]; + + public List AvailableSubtypes { get; set; } = + [ + "api", + "behavioral", + "configuration", + "dependency", + "subscription", + "plugin", + "security", + "other" + ]; + + public List AvailableLifecycles { get; set; } = + [ + "preview", + "beta", + "ga" + ]; + + public static ReleaseNotesConfiguration Default => new(); +} + +public class LabelMappings +{ + /// + /// Maps PR labels to type values (e.g., "bug" -> "bug-fix") + /// + public Dictionary Type { get; set; } = []; + + /// + /// Maps PR labels to subtype values (e.g., "breaking:api" -> "api") + /// + public Dictionary Subtype { get; set; } = []; + + /// + /// Maps PR labels to product IDs (e.g., "product:elasticsearch" -> "elasticsearch") + /// + public Dictionary Product { get; set; } = []; + + /// + /// Maps PR labels to area values (e.g., "area:search" -> "search") + /// + public Dictionary Area { get; set; } = []; + + /// + /// Maps PR labels to lifecycle values (e.g., "lifecycle:preview" -> "preview") + /// + public Dictionary Lifecycle { get; set; } = []; + + /// + /// Maps PR labels to highlight flag (e.g., "highlight" -> true) + /// + public Dictionary Highlight { get; set; } = []; +} diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs new file mode 100644 index 000000000..75797cc19 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs @@ -0,0 +1,35 @@ +// 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 + +namespace Elastic.Documentation.Services.ReleaseNotes; + +/// +/// Data structure for release notes YAML file matching the exact schema +/// +public class ReleaseNotesData +{ + // Automated fields + public int Id { get; set; } + public string? Pr { get; set; } + public List? Issues { get; set; } + public string Type { get; set; } = string.Empty; + public string? Subtype { get; set; } + public List Products { get; set; } = []; + public List? Areas { get; set; } + + // Non-automated fields + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Impact { get; set; } + public string? Action { get; set; } + public string? FeatureId { get; set; } + public bool? Highlight { get; set; } +} + +public class ProductInfo +{ + public string Product { get; set; } = string.Empty; + public string? Target { get; set; } + public string? Lifecycle { get; set; } +} diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs new file mode 100644 index 000000000..c7f371ba2 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs @@ -0,0 +1,29 @@ +// 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 + +namespace Elastic.Documentation.Services.ReleaseNotes; + +/// +/// Input data for creating a release notes changelog fragment +/// +public class ReleaseNotesInput +{ + public required string Title { get; set; } + public required string Type { get; set; } + public required string[] Products { get; set; } + public string? Subtype { get; set; } + public string[] Areas { get; set; } = []; + public string? Pr { get; set; } + public string[] Issues { get; set; } = []; + public string? Description { get; set; } + public string? Impact { get; set; } + public string? Action { get; set; } + public string? FeatureId { get; set; } + public bool? Highlight { get; set; } + public string? Lifecycle { get; set; } + public string? Target { get; set; } + public int? Id { get; set; } + public string? Output { get; set; } +} + diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs new file mode 100644 index 000000000..9f1e142d5 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs @@ -0,0 +1,15 @@ +// 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 YamlDotNet.Serialization; + +namespace Elastic.Documentation.Services.ReleaseNotes; + +[YamlStaticContext] +[YamlSerializable(typeof(ReleaseNotesData))] +[YamlSerializable(typeof(ProductInfo))] +[YamlSerializable(typeof(ReleaseNotesConfiguration))] +[YamlSerializable(typeof(LabelMappings))] +public partial class ReleaseNotesYamlStaticContext; + diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs new file mode 100644 index 000000000..07a251af5 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -0,0 +1,260 @@ +// 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.Globalization; +using System.IO.Abstractions; +using System.Security.Cryptography; +using System.Text; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services.ReleaseNotes; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace Elastic.Documentation.Services; + +public class ReleaseNotesService( + ILoggerFactory logFactory, + IConfigurationContext configurationContext +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = new FileSystem(); + + public async Task CreateReleaseNotes( + IDiagnosticsCollector collector, + ReleaseNotesInput input, + Cancel ctx + ) + { + try + { + // Load release notes configuration + var config = await LoadReleaseNotesConfiguration(collector, ctx); + if (config == null) + { + collector.EmitError(string.Empty, "Failed to load release notes configuration"); + return false; + } + + // Validate required fields + if (string.IsNullOrWhiteSpace(input.Title)) + { + collector.EmitError(string.Empty, "Title is required"); + return false; + } + + if (string.IsNullOrWhiteSpace(input.Type)) + { + collector.EmitError(string.Empty, "Type is required"); + return false; + } + + if (input.Products.Length == 0) + { + collector.EmitError(string.Empty, "At least one product is required"); + return false; + } + + // Validate type is in allowed list + if (!config.AvailableTypes.Contains(input.Type)) + { + collector.EmitWarning(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); + } + + // Generate unique ID if not provided + var id = input.Id ?? GenerateUniqueId(input.Title, input.Pr ?? string.Empty); + + // Build release notes data from input + var releaseNotesData = BuildReleaseNotesData(input, id); + + // Generate YAML file + var yamlContent = GenerateYaml(releaseNotesData, config); + + // Determine output path + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!_fileSystem.Directory.Exists(outputDir)) + { + _ = _fileSystem.Directory.CreateDirectory(outputDir); + } + + // Generate filename (timestamp-slug.yaml) + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var slug = SanitizeFilename(input.Title); + var filename = $"{timestamp}-{slug}.yaml"; + var filePath = Path.Combine(outputDir, filename); + + // Write file + await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); + _logger.LogInformation("Created release notes fragment: {FilePath}", filePath); + + return true; + } + catch (Exception ex) + { + collector.EmitError(string.Empty, $"Error creating release notes: {ex.Message}", ex); + return false; + } + } + + private async Task LoadReleaseNotesConfiguration( + IDiagnosticsCollector collector, + Cancel ctx + ) + { + // Try to load from config directory + _ = configurationContext; // Suppress unused warning - kept for future extensibility + var configPath = Path.Combine(Elastic.Documentation.Configuration.ConfigurationFileProvider.LocalConfigurationDirectory, "release-notes.yml"); + + if (!_fileSystem.File.Exists(configPath)) + { + // Use default configuration if file doesn't exist + _logger.LogWarning("Release notes configuration not found at {ConfigPath}, using defaults", configPath); + return ReleaseNotesConfiguration.Default; + } + + try + { + var yamlContent = await _fileSystem.File.ReadAllTextAsync(configPath, ctx); + var deserializer = new StaticDeserializerBuilder(new ReleaseNotesYamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + + var config = deserializer.Deserialize(yamlContent); + return config; + } + catch (Exception ex) + { + collector.EmitError(configPath, $"Failed to load release notes configuration: {ex.Message}", ex); + return null; + } + } + + private static int GenerateUniqueId(string title, string prUrl) + { + // Generate a unique ID based on title and PR URL hash + var input = $"{title}-{prUrl}"; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + // Take first 4 bytes and convert to positive integer + var id = Math.Abs(BitConverter.ToInt32(hash, 0)); + return id; + } + + private static ReleaseNotesData BuildReleaseNotesData(ReleaseNotesInput input, int id) + { + var data = new ReleaseNotesData + { + Id = id, + Title = input.Title, + Type = input.Type, + Subtype = input.Subtype, + Description = input.Description, + Impact = input.Impact, + Action = input.Action, + FeatureId = input.FeatureId, + Highlight = input.Highlight, + Pr = input.Pr, + Products = input.Products.Select(p => new ProductInfo + { + Product = p, + Target = input.Target, + Lifecycle = input.Lifecycle + }).ToList() + }; + + if (input.Areas.Length > 0) + { + data.Areas = input.Areas.ToList(); + } + + if (input.Issues.Length > 0) + { + data.Issues = input.Issues.ToList(); + } + + return data; + } + + private string GenerateYaml(ReleaseNotesData data, ReleaseNotesConfiguration config) + { + // Ensure areas is null if empty to omit it from YAML + if (data.Areas != null && data.Areas.Count == 0) + data.Areas = null; + + // Ensure issues is null if empty to omit it from YAML + if (data.Issues != null && data.Issues.Count == 0) + data.Issues = null; + + var serializer = new StaticSerializerBuilder(new ReleaseNotesYamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) + .Build(); + + var yaml = serializer.Serialize(data); + + // Add schema comments + var sb = new StringBuilder(); + _ = sb.AppendLine("##### Automated fields #####"); + _ = sb.AppendLine(); + _ = sb.AppendLine("# These fields are likely generated when the changelog is created and unlikely to require edits"); + _ = sb.AppendLine(); + _ = sb.AppendLine("# id: A required number that is a unique identifier for this changelog"); + _ = sb.AppendLine("# pr: An optional string that contains the pull request URL"); + _ = sb.AppendLine("# issues: An optional array of strings that contain URLs for issues that are relevant to the PR"); + _ = sb.AppendLine("# type: A required string that contains the type of change"); + _ = sb.AppendLine("# It can be one of:"); + foreach (var type in config.AvailableTypes) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"# - {type}"); + } + _ = sb.AppendLine("# subtype: An optional string that applies only to breaking changes"); + if (config.AvailableSubtypes.Count > 0) + { + _ = sb.AppendLine("# It can be one of:"); + foreach (var subtype in config.AvailableSubtypes) + { + _ = sb.AppendLine(CultureInfo.InvariantCulture, $"# - {subtype}"); + } + } + _ = sb.AppendLine("# products: A required array of objects that denote the affected products"); + _ = sb.AppendLine("# Each product object contains:"); + _ = sb.AppendLine("# - product: A required string with a predefined product ID"); + _ = sb.AppendLine("# - target: An optional string with the target version or date"); + _ = sb.AppendLine("# - lifecycle: An optional string (preview, beta, ga)"); + _ = sb.AppendLine("# areas: An optional array of strings that denotes the parts/components/services affected"); + _ = sb.AppendLine(); + _ = sb.AppendLine("##### Non-automated fields #####"); + _ = sb.AppendLine(); + _ = sb.AppendLine("# These fields might be generated when the changelog is created but are likely to require edits"); + _ = sb.AppendLine(); + _ = sb.AppendLine("# title: A required string that is a short, user-facing headline (Max 80 characters)"); + _ = sb.AppendLine("# description: An optional string that provides additional information (Max 600 characters)"); + _ = sb.AppendLine("# impact: An optional string that describes how the user's environment is affected"); + _ = sb.AppendLine("# action: An optional string that describes what users must do to mitigate"); + _ = sb.AppendLine("# feature-id: An optional string to associate with a unique feature flag"); + _ = sb.AppendLine("# highlight: An optional boolean for items that should be included in release highlights"); + _ = sb.AppendLine(); + _ = sb.Append(yaml); + + return sb.ToString(); + } + + private static string SanitizeFilename(string input) + { + var sanitized = input.ToLowerInvariant() + .Replace(" ", "-") + .Replace("/", "-") + .Replace("\\", "-") + .Replace(":", "") + .Replace("'", "") + .Replace("\"", ""); + + // Limit length + if (sanitized.Length > 50) + sanitized = sanitized[..50]; + + return sanitized; + } +} diff --git a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs new file mode 100644 index 000000000..702f3f278 --- /dev/null +++ b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs @@ -0,0 +1,101 @@ +// 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 ConsoleAppFramework; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services; +using Elastic.Documentation.Services.ReleaseNotes; +using Microsoft.Extensions.Logging; + +namespace Documentation.Builder.Commands; + +internal sealed class ReleaseNotesCommand( + ILoggerFactory logFactory, + IDiagnosticsCollector collector, + IConfigurationContext configurationContext +) +{ + /// + /// Release notes commands. Use 'release-notes create' to create a new changelog fragment. + /// + [Command("")] + public Task Default() + { + collector.EmitError(string.Empty, "Please specify a subcommand. Use 'release-notes create' to create a new changelog fragment. Run 'release-notes create --help' for usage information."); + return Task.FromResult(1); + } + + /// + /// Create a new release notes changelog fragment from command-line input + /// + /// Required: A short, user-facing headline (max 80 characters) + /// Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.) + /// Required: Product ID(s) affected (comma-separated or specify multiple times) + /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) + /// Optional: Area(s) affected (comma-separated or specify multiple times) + /// Optional: Pull request URL + /// Optional: Issue URL(s) (comma-separated or specify multiple times) + /// Optional: Additional information about the change (max 600 characters) + /// Optional: How the user's environment is affected + /// Optional: What users must do to mitigate + /// Optional: Feature flag ID + /// Optional: Include in release highlights + /// Optional: Lifecycle stage (preview, beta, ga) + /// Optional: Target version or date + /// Optional: Custom ID (auto-generated if not provided) + /// Optional: Output directory for the changelog fragment. Defaults to current directory + /// + [Command("create")] + public async Task Create( + string headline, + string type, + string[] product, + string? subtype = null, + string[]? area = null, + string? pr = null, + string[]? issues = null, + string? description = null, + string? impact = null, + string? action = null, + string? featureId = null, + bool? highlight = null, + string? lifecycle = null, + string? target = null, + int? id = null, + string? output = null, + Cancel ctx = default + ) + { + await using var serviceInvoker = new ServiceInvoker(collector); + + var service = new ReleaseNotesService(logFactory, configurationContext); + + var input = new ReleaseNotesInput + { + Title = headline, + Type = type, + Products = product, + Subtype = subtype, + Areas = area ?? [], + Pr = pr, + Issues = issues ?? [], + Description = description, + Impact = impact, + Action = action, + FeatureId = featureId, + Highlight = highlight, + Lifecycle = lifecycle, + Target = target, + Id = id, + Output = output + }; + + serviceInvoker.AddCommand(service, input, + async static (s, collector, state, ctx) => await s.CreateReleaseNotes(collector, state, ctx) + ); + + return await serviceInvoker.InvokeAsync(ctx); + } +} diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index a99f7538c..aac105da9 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -34,6 +34,7 @@ app.Add("serve"); app.Add("index"); app.Add("format"); +app.Add("release-notes"); //assembler commands From 67e4f85bd5e92808b62d2fd931f8f4b677b31f44 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 1 Dec 2025 18:58:18 -0800 Subject: [PATCH 02/21] Fix IDE0002 linting error --- .../Elastic.Documentation.Services/ReleaseNotesService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index 07a251af5..f2f11de1a 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; +using static Elastic.Documentation.Configuration.ConfigurationFileProvider; namespace Elastic.Documentation.Services; @@ -106,7 +107,7 @@ Cancel ctx { // Try to load from config directory _ = configurationContext; // Suppress unused warning - kept for future extensibility - var configPath = Path.Combine(Elastic.Documentation.Configuration.ConfigurationFileProvider.LocalConfigurationDirectory, "release-notes.yml"); + var configPath = Path.Combine(LocalConfigurationDirectory, "release-notes.yml"); if (!_fileSystem.File.Exists(configPath)) { From dd3a7c4d131201c05b945115ce0aad524718bfdc Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 1 Dec 2025 19:21:20 -0800 Subject: [PATCH 03/21] Change headline to title command option --- src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs index 702f3f278..27f7fd622 100644 --- a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs +++ b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs @@ -30,7 +30,7 @@ public Task Default() /// /// Create a new release notes changelog fragment from command-line input /// - /// Required: A short, user-facing headline (max 80 characters) + /// Required: A short, user-facing title (max 80 characters) /// Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.) /// Required: Product ID(s) affected (comma-separated or specify multiple times) /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) @@ -49,7 +49,7 @@ public Task Default() /// [Command("create")] public async Task Create( - string headline, + string title, string type, string[] product, string? subtype = null, @@ -74,7 +74,7 @@ public async Task Create( var input = new ReleaseNotesInput { - Title = headline, + Title = title, Type = type, Products = product, Subtype = subtype, From 5f4677493a00f4a8dd95ad2e50957f8106da19aa Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 2 Dec 2025 12:49:03 -0800 Subject: [PATCH 04/21] Potential fix for pull request finding 'Call to System.IO.Path.Combine' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Elastic.Documentation.Services/ReleaseNotesService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index f2f11de1a..ebf8f2f7e 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -85,7 +85,7 @@ Cancel ctx var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var slug = SanitizeFilename(input.Title); var filename = $"{timestamp}-{slug}.yaml"; - var filePath = Path.Combine(outputDir, filename); + var filePath = _fileSystem.Path.Combine(outputDir, filename); // Write file await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); From 26a1f69522b5a3933be04a0c18629a5a3facb6a9 Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 2 Dec 2025 13:01:54 -0800 Subject: [PATCH 05/21] Apply github-code-quality suggestions --- .../ReleaseNotesService.cs | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index ebf8f2f7e..0301cc08a 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -12,6 +12,7 @@ using Microsoft.Extensions.Logging; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; +using YamlDotNet.Core; using static Elastic.Documentation.Configuration.ConfigurationFileProvider; namespace Elastic.Documentation.Services; @@ -93,11 +94,21 @@ Cancel ctx return true; } - catch (Exception ex) + catch (OperationCanceledException) { - collector.EmitError(string.Empty, $"Error creating release notes: {ex.Message}", ex); + // If cancelled, don't emit error; propagate cancellation signal. + throw; + } + catch (IOException ioEx) + { + collector.EmitError(string.Empty, $"IO error creating release notes: {ioEx.Message}", ioEx); return false; } + catch (UnauthorizedAccessException uaEx) + { + collector.EmitError(string.Empty, $"Access denied creating release notes: {uaEx.Message}", uaEx); + return false; + } } private async Task LoadReleaseNotesConfiguration( @@ -107,7 +118,7 @@ Cancel ctx { // Try to load from config directory _ = configurationContext; // Suppress unused warning - kept for future extensibility - var configPath = Path.Combine(LocalConfigurationDirectory, "release-notes.yml"); + var configPath = _fileSystem.Path.Combine(LocalConfigurationDirectory, "release-notes.yml"); if (!_fileSystem.File.Exists(configPath)) { @@ -126,11 +137,21 @@ Cancel ctx var config = deserializer.Deserialize(yamlContent); return config; } - catch (Exception ex) + catch (IOException ex) { - collector.EmitError(configPath, $"Failed to load release notes configuration: {ex.Message}", ex); + collector.EmitError(configPath, $"I/O error loading release notes configuration: {ex.Message}", ex); return null; } + catch (UnauthorizedAccessException ex) + { + collector.EmitError(configPath, $"Access denied loading release notes configuration: {ex.Message}", ex); + return null; + } + catch (YamlException ex) + { + collector.EmitError(configPath, $"YAML parsing error in release notes configuration: {ex.Message}", ex); + return null; + } } private static int GenerateUniqueId(string title, string prUrl) From a9be7643bc2a545f1dae88afb77aed61774c8bb4 Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 2 Dec 2025 13:09:01 -0800 Subject: [PATCH 06/21] Fix formatting --- .../ReleaseNotesService.cs | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index 0301cc08a..03593fed0 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -10,9 +10,9 @@ using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services.ReleaseNotes; using Microsoft.Extensions.Logging; +using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; -using YamlDotNet.Core; using static Elastic.Documentation.Configuration.ConfigurationFileProvider; namespace Elastic.Documentation.Services; @@ -97,18 +97,18 @@ Cancel ctx catch (OperationCanceledException) { // If cancelled, don't emit error; propagate cancellation signal. - throw; - } - catch (IOException ioEx) - { - collector.EmitError(string.Empty, $"IO error creating release notes: {ioEx.Message}", ioEx); + throw; + } + catch (IOException ioEx) + { + collector.EmitError(string.Empty, $"IO error creating release notes: {ioEx.Message}", ioEx); return false; } catch (UnauthorizedAccessException uaEx) - { - collector.EmitError(string.Empty, $"Access denied creating release notes: {uaEx.Message}", uaEx); - return false; - } + { + collector.EmitError(string.Empty, $"Access denied creating release notes: {uaEx.Message}", uaEx); + return false; + } } private async Task LoadReleaseNotesConfiguration( @@ -143,15 +143,15 @@ Cancel ctx return null; } catch (UnauthorizedAccessException ex) - { - collector.EmitError(configPath, $"Access denied loading release notes configuration: {ex.Message}", ex); - return null; - } - catch (YamlException ex) - { - collector.EmitError(configPath, $"YAML parsing error in release notes configuration: {ex.Message}", ex); - return null; - } + { + collector.EmitError(configPath, $"Access denied loading release notes configuration: {ex.Message}", ex); + return null; + } + catch (YamlException ex) + { + collector.EmitError(configPath, $"YAML parsing error in release notes configuration: {ex.Message}", ex); + return null; + } } private static int GenerateUniqueId(string title, string prUrl) From 7f940c640c8b7241f8629f80950f1cd30e4961d6 Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 2 Dec 2025 14:20:44 -0800 Subject: [PATCH 07/21] Use --products instead of --product, target, and lifecycle options --- .../ReleaseNotes/ReleaseNotesInput.cs | 4 +- .../ReleaseNotesService.cs | 9 +--- .../Arguments/ProductInfoParser.cs | 51 +++++++++++++++++++ .../Commands/ReleaseNotesCommand.cs | 13 ++--- 4 files changed, 58 insertions(+), 19 deletions(-) create mode 100644 src/tooling/docs-builder/Arguments/ProductInfoParser.cs diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs index c7f371ba2..6794f37f2 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs @@ -11,7 +11,7 @@ public class ReleaseNotesInput { public required string Title { get; set; } public required string Type { get; set; } - public required string[] Products { get; set; } + public required List Products { get; set; } public string? Subtype { get; set; } public string[] Areas { get; set; } = []; public string? Pr { get; set; } @@ -21,8 +21,6 @@ public class ReleaseNotesInput public string? Action { get; set; } public string? FeatureId { get; set; } public bool? Highlight { get; set; } - public string? Lifecycle { get; set; } - public string? Target { get; set; } public int? Id { get; set; } public string? Output { get; set; } } diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index 03593fed0..1abb6ddb5 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -54,7 +54,7 @@ Cancel ctx return false; } - if (input.Products.Length == 0) + if (input.Products.Count == 0) { collector.EmitError(string.Empty, "At least one product is required"); return false; @@ -178,12 +178,7 @@ private static ReleaseNotesData BuildReleaseNotesData(ReleaseNotesInput input, i FeatureId = input.FeatureId, Highlight = input.Highlight, Pr = input.Pr, - Products = input.Products.Select(p => new ProductInfo - { - Product = p, - Target = input.Target, - Lifecycle = input.Lifecycle - }).ToList() + Products = input.Products }; if (input.Areas.Length > 0) diff --git a/src/tooling/docs-builder/Arguments/ProductInfoParser.cs b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs new file mode 100644 index 000000000..4cc7bb28c --- /dev/null +++ b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs @@ -0,0 +1,51 @@ +// 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 ConsoleAppFramework; +using Elastic.Documentation.Services.ReleaseNotes; + +namespace Documentation.Builder.Arguments; + +[AttributeUsage(AttributeTargets.Parameter)] +public class ProductInfoParserAttribute : Attribute, IArgumentParser> +{ + public static bool TryParse(ReadOnlySpan s, out List result) + { + result = []; + + // Split by comma to get individual product entries + var productEntries = s.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + foreach (var entry in productEntries) + { + // Split by whitespace to get product, target, lifecycle + var parts = entry.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (parts.Length == 0) + continue; + + var productInfo = new ProductInfo + { + Product = parts[0] + }; + + // Target is optional (second part) + if (parts.Length > 1) + { + productInfo.Target = parts[1]; + } + + // Lifecycle is optional (third part) + if (parts.Length > 2) + { + productInfo.Lifecycle = parts[2]; + } + + result.Add(productInfo); + } + + return result.Count > 0; + } +} + diff --git a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs index 27f7fd622..64b04402a 100644 --- a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs +++ b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for more information using ConsoleAppFramework; +using Documentation.Builder.Arguments; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; @@ -32,7 +33,7 @@ public Task Default() /// /// Required: A short, user-facing title (max 80 characters) /// Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.) - /// Required: Product ID(s) affected (comma-separated or specify multiple times) + /// Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05") /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) /// Optional: Area(s) affected (comma-separated or specify multiple times) /// Optional: Pull request URL @@ -42,8 +43,6 @@ public Task Default() /// Optional: What users must do to mitigate /// Optional: Feature flag ID /// Optional: Include in release highlights - /// Optional: Lifecycle stage (preview, beta, ga) - /// Optional: Target version or date /// Optional: Custom ID (auto-generated if not provided) /// Optional: Output directory for the changelog fragment. Defaults to current directory /// @@ -51,7 +50,7 @@ public Task Default() public async Task Create( string title, string type, - string[] product, + [ProductInfoParser] List products, string? subtype = null, string[]? area = null, string? pr = null, @@ -61,8 +60,6 @@ public async Task Create( string? action = null, string? featureId = null, bool? highlight = null, - string? lifecycle = null, - string? target = null, int? id = null, string? output = null, Cancel ctx = default @@ -76,7 +73,7 @@ public async Task Create( { Title = title, Type = type, - Products = product, + Products = products, Subtype = subtype, Areas = area ?? [], Pr = pr, @@ -86,8 +83,6 @@ public async Task Create( Action = action, FeatureId = featureId, Highlight = highlight, - Lifecycle = lifecycle, - Target = target, Id = id, Output = output }; From c8f363386d1555ea0889706cd0a67d7be35c7801 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 14:17:38 -0800 Subject: [PATCH 08/21] Remove --id command option --- .../ReleaseNotes/ReleaseNotesData.cs | 1 - .../ReleaseNotes/ReleaseNotesInput.cs | 1 - .../ReleaseNotesService.cs | 22 +++---------------- .../Commands/ReleaseNotesCommand.cs | 3 --- 4 files changed, 3 insertions(+), 24 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs index 75797cc19..9cbc0d39d 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs @@ -10,7 +10,6 @@ namespace Elastic.Documentation.Services.ReleaseNotes; public class ReleaseNotesData { // Automated fields - public int Id { get; set; } public string? Pr { get; set; } public List? Issues { get; set; } public string Type { get; set; } = string.Empty; diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs index 6794f37f2..0d7695264 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs @@ -21,7 +21,6 @@ public class ReleaseNotesInput public string? Action { get; set; } public string? FeatureId { get; set; } public bool? Highlight { get; set; } - public int? Id { get; set; } public string? Output { get; set; } } diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index 1abb6ddb5..e0f15d8fa 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.IO.Abstractions; -using System.Security.Cryptography; using System.Text; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; @@ -66,11 +65,8 @@ Cancel ctx collector.EmitWarning(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); } - // Generate unique ID if not provided - var id = input.Id ?? GenerateUniqueId(input.Title, input.Pr ?? string.Empty); - // Build release notes data from input - var releaseNotesData = BuildReleaseNotesData(input, id); + var releaseNotesData = BuildReleaseNotesData(input); // Generate YAML file var yamlContent = GenerateYaml(releaseNotesData, config); @@ -154,21 +150,10 @@ Cancel ctx } } - private static int GenerateUniqueId(string title, string prUrl) - { - // Generate a unique ID based on title and PR URL hash - var input = $"{title}-{prUrl}"; - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); - // Take first 4 bytes and convert to positive integer - var id = Math.Abs(BitConverter.ToInt32(hash, 0)); - return id; - } - - private static ReleaseNotesData BuildReleaseNotesData(ReleaseNotesInput input, int id) + private static ReleaseNotesData BuildReleaseNotesData(ReleaseNotesInput input) { var data = new ReleaseNotesData { - Id = id, Title = input.Title, Type = input.Type, Subtype = input.Subtype, @@ -217,8 +202,7 @@ private string GenerateYaml(ReleaseNotesData data, ReleaseNotesConfiguration con _ = sb.AppendLine(); _ = sb.AppendLine("# These fields are likely generated when the changelog is created and unlikely to require edits"); _ = sb.AppendLine(); - _ = sb.AppendLine("# id: A required number that is a unique identifier for this changelog"); - _ = sb.AppendLine("# pr: An optional string that contains the pull request URL"); + _ = sb.AppendLine("# pr: An optional string that contains the pull request number"); _ = sb.AppendLine("# issues: An optional array of strings that contain URLs for issues that are relevant to the PR"); _ = sb.AppendLine("# type: A required string that contains the type of change"); _ = sb.AppendLine("# It can be one of:"); diff --git a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs index 64b04402a..e994e8d24 100644 --- a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs +++ b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs @@ -43,7 +43,6 @@ public Task Default() /// Optional: What users must do to mitigate /// Optional: Feature flag ID /// Optional: Include in release highlights - /// Optional: Custom ID (auto-generated if not provided) /// Optional: Output directory for the changelog fragment. Defaults to current directory /// [Command("create")] @@ -60,7 +59,6 @@ public async Task Create( string? action = null, string? featureId = null, bool? highlight = null, - int? id = null, string? output = null, Cancel ctx = default ) @@ -83,7 +81,6 @@ public async Task Create( Action = action, FeatureId = featureId, Highlight = highlight, - Id = id, Output = output }; From e766a3ff062a2f8bd6437eeabcbd541702053d32 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 14:36:10 -0800 Subject: [PATCH 09/21] Rename command to changelog add --- src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs | 8 ++++---- src/tooling/docs-builder/Program.cs | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs index e994e8d24..6f0b2747c 100644 --- a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs +++ b/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs @@ -19,17 +19,17 @@ IConfigurationContext configurationContext ) { /// - /// Release notes commands. Use 'release-notes create' to create a new changelog fragment. + /// Changelog commands. Use 'changelog add' to create a new changelog fragment. /// [Command("")] public Task Default() { - collector.EmitError(string.Empty, "Please specify a subcommand. Use 'release-notes create' to create a new changelog fragment. Run 'release-notes create --help' for usage information."); + collector.EmitError(string.Empty, "Please specify a subcommand. Use 'changelog add' to create a new changelog fragment. Run 'changelog add --help' for usage information."); return Task.FromResult(1); } /// - /// Create a new release notes changelog fragment from command-line input + /// Add a new changelog fragment from command-line input /// /// Required: A short, user-facing title (max 80 characters) /// Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.) @@ -45,7 +45,7 @@ public Task Default() /// Optional: Include in release highlights /// Optional: Output directory for the changelog fragment. Defaults to current directory /// - [Command("create")] + [Command("add")] public async Task Create( string title, string type, diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index aac105da9..8041cb027 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -34,7 +34,7 @@ app.Add("serve"); app.Add("index"); app.Add("format"); -app.Add("release-notes"); +app.Add("changelog"); //assembler commands From 2492be293ce07aac075c45223af8cfa9221b726b Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 14:56:40 -0800 Subject: [PATCH 10/21] Add config command option --- .../ReleaseNotes/ReleaseNotesInput.cs | 1 + .../ReleaseNotesService.cs | 26 +++++++++---------- ...aseNotesCommand.cs => ChangelogCommand.cs} | 8 ++++-- src/tooling/docs-builder/Program.cs | 2 +- 4 files changed, 21 insertions(+), 16 deletions(-) rename src/tooling/docs-builder/Commands/{ReleaseNotesCommand.cs => ChangelogCommand.cs} (93%) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs index 0d7695264..d741b520f 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs @@ -22,5 +22,6 @@ public class ReleaseNotesInput public string? FeatureId { get; set; } public bool? Highlight { get; set; } public string? Output { get; set; } + public string? Config { get; set; } } diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index e0f15d8fa..a0c758693 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -12,7 +12,6 @@ using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; -using static Elastic.Documentation.Configuration.ConfigurationFileProvider; namespace Elastic.Documentation.Services; @@ -32,11 +31,11 @@ Cancel ctx { try { - // Load release notes configuration - var config = await LoadReleaseNotesConfiguration(collector, ctx); + // Load changelog configuration + var config = await LoadReleaseNotesConfiguration(collector, input.Config, ctx); if (config == null) { - collector.EmitError(string.Empty, "Failed to load release notes configuration"); + collector.EmitError(string.Empty, "Failed to load changelog configuration"); return false; } @@ -86,7 +85,7 @@ Cancel ctx // Write file await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); - _logger.LogInformation("Created release notes fragment: {FilePath}", filePath); + _logger.LogInformation("Created changelog fragment: {FilePath}", filePath); return true; } @@ -109,23 +108,24 @@ Cancel ctx private async Task LoadReleaseNotesConfiguration( IDiagnosticsCollector collector, + string? configPath, Cancel ctx ) { - // Try to load from config directory + // Determine config file path _ = configurationContext; // Suppress unused warning - kept for future extensibility - var configPath = _fileSystem.Path.Combine(LocalConfigurationDirectory, "release-notes.yml"); + var finalConfigPath = configPath ?? _fileSystem.Path.Combine(Directory.GetCurrentDirectory(), "docs", "changelog.yml"); - if (!_fileSystem.File.Exists(configPath)) + if (!_fileSystem.File.Exists(finalConfigPath)) { // Use default configuration if file doesn't exist - _logger.LogWarning("Release notes configuration not found at {ConfigPath}, using defaults", configPath); + _logger.LogWarning("Changelog configuration not found at {ConfigPath}, using defaults", finalConfigPath); return ReleaseNotesConfiguration.Default; } try { - var yamlContent = await _fileSystem.File.ReadAllTextAsync(configPath, ctx); + var yamlContent = await _fileSystem.File.ReadAllTextAsync(finalConfigPath, ctx); var deserializer = new StaticDeserializerBuilder(new ReleaseNotesYamlStaticContext()) .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); @@ -135,17 +135,17 @@ Cancel ctx } catch (IOException ex) { - collector.EmitError(configPath, $"I/O error loading release notes configuration: {ex.Message}", ex); + collector.EmitError(finalConfigPath, $"I/O error loading changelog configuration: {ex.Message}", ex); return null; } catch (UnauthorizedAccessException ex) { - collector.EmitError(configPath, $"Access denied loading release notes configuration: {ex.Message}", ex); + collector.EmitError(finalConfigPath, $"Access denied loading changelog configuration: {ex.Message}", ex); return null; } catch (YamlException ex) { - collector.EmitError(configPath, $"YAML parsing error in release notes configuration: {ex.Message}", ex); + collector.EmitError(finalConfigPath, $"YAML parsing error in changelog configuration: {ex.Message}", ex); return null; } } diff --git a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs similarity index 93% rename from src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs rename to src/tooling/docs-builder/Commands/ChangelogCommand.cs index 6f0b2747c..f25d6ac85 100644 --- a/src/tooling/docs-builder/Commands/ReleaseNotesCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -12,7 +12,7 @@ namespace Documentation.Builder.Commands; -internal sealed class ReleaseNotesCommand( +internal sealed class ChangelogCommand( ILoggerFactory logFactory, IDiagnosticsCollector collector, IConfigurationContext configurationContext @@ -44,6 +44,7 @@ public Task Default() /// Optional: Feature flag ID /// Optional: Include in release highlights /// Optional: Output directory for the changelog fragment. Defaults to current directory + /// Optional: Path to the changelog.yml configuration file. Defaults to 'docs/changelog.yml' /// [Command("add")] public async Task Create( @@ -60,6 +61,7 @@ public async Task Create( string? featureId = null, bool? highlight = null, string? output = null, + string? config = null, Cancel ctx = default ) { @@ -81,7 +83,8 @@ public async Task Create( Action = action, FeatureId = featureId, Highlight = highlight, - Output = output + Output = output, + Config = config }; serviceInvoker.AddCommand(service, input, @@ -91,3 +94,4 @@ async static (s, collector, state, ctx) => await s.CreateReleaseNotes(collector, return await serviceInvoker.InvokeAsync(ctx); } } + diff --git a/src/tooling/docs-builder/Program.cs b/src/tooling/docs-builder/Program.cs index 8041cb027..fe06e63f7 100644 --- a/src/tooling/docs-builder/Program.cs +++ b/src/tooling/docs-builder/Program.cs @@ -34,7 +34,7 @@ app.Add("serve"); app.Add("index"); app.Add("format"); -app.Add("changelog"); +app.Add("changelog"); //assembler commands From c255c5e3f02c3fa4071322c38dd5348d6c570a59 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 15:06:23 -0800 Subject: [PATCH 11/21] Rename config example; remove labelmappings --- config/changelog.yml.example | 34 +++++++ config/release-notes.yml.example | 92 ------------------- .../ReleaseNotes/ReleaseNotesConfiguration.cs | 33 ------- .../ReleaseNotesYamlStaticContext.cs | 1 - 4 files changed, 34 insertions(+), 126 deletions(-) create mode 100644 config/changelog.yml.example delete mode 100644 config/release-notes.yml.example diff --git a/config/changelog.yml.example b/config/changelog.yml.example new file mode 100644 index 000000000..239fcbeb4 --- /dev/null +++ b/config/changelog.yml.example @@ -0,0 +1,34 @@ +# Changelog Configuration +# This file configures the valid values for changelog fields. +# Place this file as `changelog.yml` in the `docs/` directory + +# Available types for changelog entries +available_types: + - feature + - enhancement + - bug-fix + - known-issue + - breaking-change + - deprecation + - docs + - regression + - security + - other + +# Available subtypes for breaking changes +available_subtypes: + - api + - behavioral + - configuration + - dependency + - subscription + - plugin + - security + - other + +# Available lifecycle values +available_lifecycles: + - preview + - beta + - ga + diff --git a/config/release-notes.yml.example b/config/release-notes.yml.example deleted file mode 100644 index 108d2f9d9..000000000 --- a/config/release-notes.yml.example +++ /dev/null @@ -1,92 +0,0 @@ -# Release Notes Configuration -# This file configures how PR labels are mapped to YAML fields in release notes fragments -# Place this file as `release-notes.yml` in the `config/` directory - -# Available types for release notes -available_types: - - feature - - enhancement - - bug-fix - - known-issue - - breaking-change - - deprecation - - docs - - regression - - security - - other - -# Available subtypes for breaking changes -available_subtypes: - - api - - behavioral - - configuration - - dependency - - subscription - - plugin - - security - - other - -# Available lifecycle values -available_lifecycles: - - preview - - beta - - ga - -# Label mappings - maps GitHub PR labels to YAML field values -label_mappings: - # Maps PR labels to "type" field - type: - bug: bug-fix - enhancement: enhancement - feature: feature - breaking: breaking-change - deprecation: deprecation - security: security - docs: docs - regression: regression - known-issue: known-issue - - # Maps PR labels to "subtype" field (for breaking changes) - subtype: - breaking:api: api - breaking:behavioral: behavioral - breaking:config: configuration - breaking:dependency: dependency - breaking:subscription: subscription - breaking:plugin: plugin - breaking:security: security - - # Maps PR labels to "product" field - # Add mappings for your products, e.g.: - product: - product:elasticsearch: elasticsearch - product:kibana: kibana - product:elasticsearch-client: elasticsearch-client - product:apm: apm - product:beats: beats - product:elastic-agent: elastic-agent - product:fleet: fleet - product:cloud-hosted: cloud-hosted - product:cloud-enterprise: cloud-enterprise - # Add more product mappings as needed - - # Maps PR labels to "area" field - # Areas vary by product - add mappings for your specific areas - area: - area:search: search - area:security: security - area:ml: machine-learning - area:observability: observability - area:index-management: index-management - # Add more area mappings as needed - - # Maps PR labels to "lifecycle" field - lifecycle: - lifecycle:preview: preview - lifecycle:beta: beta - lifecycle:ga: ga - - # Maps PR labels to "highlight" flag - highlight: - highlight: true - release-highlight: true diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs index 25252ccfc..567962a40 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs @@ -9,7 +9,6 @@ namespace Elastic.Documentation.Services.ReleaseNotes; /// public class ReleaseNotesConfiguration { - public LabelMappings LabelMappings { get; set; } = new(); public List AvailableTypes { get; set; } = [ "feature", @@ -46,35 +45,3 @@ public class ReleaseNotesConfiguration public static ReleaseNotesConfiguration Default => new(); } -public class LabelMappings -{ - /// - /// Maps PR labels to type values (e.g., "bug" -> "bug-fix") - /// - public Dictionary Type { get; set; } = []; - - /// - /// Maps PR labels to subtype values (e.g., "breaking:api" -> "api") - /// - public Dictionary Subtype { get; set; } = []; - - /// - /// Maps PR labels to product IDs (e.g., "product:elasticsearch" -> "elasticsearch") - /// - public Dictionary Product { get; set; } = []; - - /// - /// Maps PR labels to area values (e.g., "area:search" -> "search") - /// - public Dictionary Area { get; set; } = []; - - /// - /// Maps PR labels to lifecycle values (e.g., "lifecycle:preview" -> "preview") - /// - public Dictionary Lifecycle { get; set; } = []; - - /// - /// Maps PR labels to highlight flag (e.g., "highlight" -> true) - /// - public Dictionary Highlight { get; set; } = []; -} diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs index 9f1e142d5..50ccab913 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs @@ -10,6 +10,5 @@ namespace Elastic.Documentation.Services.ReleaseNotes; [YamlSerializable(typeof(ReleaseNotesData))] [YamlSerializable(typeof(ProductInfo))] [YamlSerializable(typeof(ReleaseNotesConfiguration))] -[YamlSerializable(typeof(LabelMappings))] public partial class ReleaseNotesYamlStaticContext; From a7f16ac53f3f87f137a2786dba5fea4bd429e69e Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 15:22:44 -0800 Subject: [PATCH 12/21] Add areas and products to the config --- config/changelog.yml.example | 22 ++++++++++++++++ .../ReleaseNotes/ReleaseNotesConfiguration.cs | 4 +++ .../ReleaseNotesService.cs | 26 +++++++++++++++++++ .../docs-builder/Commands/ChangelogCommand.cs | 6 ++--- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/config/changelog.yml.example b/config/changelog.yml.example index 239fcbeb4..47abf4266 100644 --- a/config/changelog.yml.example +++ b/config/changelog.yml.example @@ -32,3 +32,25 @@ available_lifecycles: - beta - ga +# Available areas (optional - if not specified, all areas are allowed) +available_areas: + - search + - security + - machine-learning + - observability + - index-management + # Add more areas as needed + +# Available products (optional - if not specified, all products are allowed) +available_products: + - elasticsearch + - kibana + - apm + - beats + - elastic-agent + - fleet + - cloud-hosted + - cloud-serverless + - cloud-enterprise + # Add more products as needed + diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs index 567962a40..fc27445e8 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs @@ -42,6 +42,10 @@ public class ReleaseNotesConfiguration "ga" ]; + public List? AvailableAreas { get; set; } + + public List? AvailableProducts { get; set; } + public static ReleaseNotesConfiguration Default => new(); } diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index a0c758693..bd2fc7120 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -64,6 +64,32 @@ Cancel ctx collector.EmitWarning(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); } + // Validate areas if configuration provides available areas + if (config.AvailableAreas != null && config.AvailableAreas.Count > 0) + { + foreach (var area in input.Areas) + { + if (!config.AvailableAreas.Contains(area)) + { + collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}"); + return false; + } + } + } + + // Validate products if configuration provides available products + if (config.AvailableProducts != null && config.AvailableProducts.Count > 0) + { + foreach (var product in input.Products) + { + if (!config.AvailableProducts.Contains(product.Product)) + { + collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products. Available products: {string.Join(", ", config.AvailableProducts)}"); + return false; + } + } + } + // Build release notes data from input var releaseNotesData = BuildReleaseNotesData(input); diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index f25d6ac85..ff8272a31 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -35,7 +35,7 @@ public Task Default() /// Required: Type of change (feature, enhancement, bug-fix, breaking-change, etc.) /// Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05") /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) - /// Optional: Area(s) affected (comma-separated or specify multiple times) + /// Optional: Area(s) affected (comma-separated or specify multiple times) /// Optional: Pull request URL /// Optional: Issue URL(s) (comma-separated or specify multiple times) /// Optional: Additional information about the change (max 600 characters) @@ -52,7 +52,7 @@ public async Task Create( string type, [ProductInfoParser] List products, string? subtype = null, - string[]? area = null, + string[]? areas = null, string? pr = null, string[]? issues = null, string? description = null, @@ -75,7 +75,7 @@ public async Task Create( Type = type, Products = products, Subtype = subtype, - Areas = area ?? [], + Areas = areas ?? [], Pr = pr, Issues = issues ?? [], Description = description, From 7e2cce74a3e999babeda8ed8f5a09bc7fa1e94ad Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 15:43:02 -0800 Subject: [PATCH 13/21] Validate type, subtype, and lifecycle --- ...iguration.cs => ChangelogConfiguration.cs} | 6 ++-- .../ReleaseNotesYamlStaticContext.cs | 2 +- .../ReleaseNotesService.cs | 36 +++++++++++++++---- 3 files changed, 34 insertions(+), 10 deletions(-) rename src/services/Elastic.Documentation.Services/ReleaseNotes/{ReleaseNotesConfiguration.cs => ChangelogConfiguration.cs} (83%) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ChangelogConfiguration.cs similarity index 83% rename from src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs rename to src/services/Elastic.Documentation.Services/ReleaseNotes/ChangelogConfiguration.cs index fc27445e8..d239254f2 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ChangelogConfiguration.cs @@ -5,9 +5,9 @@ namespace Elastic.Documentation.Services.ReleaseNotes; /// -/// Configuration for release notes generation, including label mappings +/// Configuration for changelog generation /// -public class ReleaseNotesConfiguration +public class ChangelogConfiguration { public List AvailableTypes { get; set; } = [ @@ -46,6 +46,6 @@ public class ReleaseNotesConfiguration public List? AvailableProducts { get; set; } - public static ReleaseNotesConfiguration Default => new(); + public static ChangelogConfiguration Default => new(); } diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs index 50ccab913..79b481003 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs @@ -9,6 +9,6 @@ namespace Elastic.Documentation.Services.ReleaseNotes; [YamlStaticContext] [YamlSerializable(typeof(ReleaseNotesData))] [YamlSerializable(typeof(ProductInfo))] -[YamlSerializable(typeof(ReleaseNotesConfiguration))] +[YamlSerializable(typeof(ChangelogConfiguration))] public partial class ReleaseNotesYamlStaticContext; diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index bd2fc7120..a524a5069 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -32,7 +32,7 @@ Cancel ctx try { // Load changelog configuration - var config = await LoadReleaseNotesConfiguration(collector, input.Config, ctx); + var config = await LoadChangelogConfiguration(collector, input.Config, ctx); if (config == null) { collector.EmitError(string.Empty, "Failed to load changelog configuration"); @@ -61,7 +61,18 @@ Cancel ctx // Validate type is in allowed list if (!config.AvailableTypes.Contains(input.Type)) { - collector.EmitWarning(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); + collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); + return false; + } + + // Validate subtype if provided + if (!string.IsNullOrWhiteSpace(input.Subtype)) + { + if (!config.AvailableSubtypes.Contains(input.Subtype)) + { + collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); + return false; + } } // Validate areas if configuration provides available areas @@ -90,6 +101,19 @@ Cancel ctx } } + // Validate lifecycle values in products + foreach (var product in input.Products) + { + if (!string.IsNullOrWhiteSpace(product.Lifecycle)) + { + if (!config.AvailableLifecycles.Contains(product.Lifecycle)) + { + collector.EmitError(string.Empty, $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", config.AvailableLifecycles)}"); + return false; + } + } + } + // Build release notes data from input var releaseNotesData = BuildReleaseNotesData(input); @@ -132,7 +156,7 @@ Cancel ctx } } - private async Task LoadReleaseNotesConfiguration( + private async Task LoadChangelogConfiguration( IDiagnosticsCollector collector, string? configPath, Cancel ctx @@ -146,7 +170,7 @@ Cancel ctx { // Use default configuration if file doesn't exist _logger.LogWarning("Changelog configuration not found at {ConfigPath}, using defaults", finalConfigPath); - return ReleaseNotesConfiguration.Default; + return ChangelogConfiguration.Default; } try @@ -156,7 +180,7 @@ Cancel ctx .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); - var config = deserializer.Deserialize(yamlContent); + var config = deserializer.Deserialize(yamlContent); return config; } catch (IOException ex) @@ -205,7 +229,7 @@ private static ReleaseNotesData BuildReleaseNotesData(ReleaseNotesInput input) return data; } - private string GenerateYaml(ReleaseNotesData data, ReleaseNotesConfiguration config) + private string GenerateYaml(ReleaseNotesData data, ChangelogConfiguration config) { // Ensure areas is null if empty to omit it from YAML if (data.Areas != null && data.Areas.Count == 0) From 7b2eeb405d962a2b1cbe72a6acf81fa3ebca8751 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 3 Dec 2025 16:03:10 -0800 Subject: [PATCH 14/21] Potential fix for pull request finding 'Missed opportunity to use Where' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../ReleaseNotesService.cs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index a524a5069..9b8f95287 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -13,6 +13,7 @@ using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; +using System.Linq; namespace Elastic.Documentation.Services; public class ReleaseNotesService( @@ -78,13 +79,10 @@ Cancel ctx // Validate areas if configuration provides available areas if (config.AvailableAreas != null && config.AvailableAreas.Count > 0) { - foreach (var area in input.Areas) + foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area))) { - if (!config.AvailableAreas.Contains(area)) - { - collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}"); - return false; - } + collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}"); + return false; } } From 0b1054dd0015825c3ce716f34cda3bbd1f4adf2a Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 3 Dec 2025 16:17:42 -0800 Subject: [PATCH 15/21] Potential fix for pull request finding 'Nested 'if' statements can be combined' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../ReleaseNotesService.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index 9b8f95287..56a88f177 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -67,13 +67,10 @@ Cancel ctx } // Validate subtype if provided - if (!string.IsNullOrWhiteSpace(input.Subtype)) + if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.AvailableSubtypes.Contains(input.Subtype)) { - if (!config.AvailableSubtypes.Contains(input.Subtype)) - { - collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); - return false; - } + collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); + return false; } // Validate areas if configuration provides available areas From 66d67ec2e6f2e449b7fcc2ee63b93bb77ae0480b Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 16:22:31 -0800 Subject: [PATCH 16/21] Fix linting --- .../Elastic.Documentation.Services/ReleaseNotesService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index 56a88f177..4c76ffb02 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO.Abstractions; +using System.Linq; using System.Text; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; @@ -12,8 +13,6 @@ using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; - -using System.Linq; namespace Elastic.Documentation.Services; public class ReleaseNotesService( From 7915e92ae9d88616182691c9c4f631b7d56c7e12 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 3 Dec 2025 16:33:29 -0800 Subject: [PATCH 17/21] Potential fix for pull request finding 'Missed opportunity to use Where' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../ReleaseNotesService.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index 4c76ffb02..af1ced2a0 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -85,13 +85,10 @@ Cancel ctx // Validate products if configuration provides available products if (config.AvailableProducts != null && config.AvailableProducts.Count > 0) { - foreach (var product in input.Products) + foreach (var product in input.Products.Where(p => !config.AvailableProducts.Contains(p.Product))) { - if (!config.AvailableProducts.Contains(product.Product)) - { - collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products. Available products: {string.Join(", ", config.AvailableProducts)}"); - return false; - } + collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products. Available products: {string.Join(", ", config.AvailableProducts)}"); + return false; } } From ace37905345b5179fd82a7182fb95cf6d2be3db9 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 3 Dec 2025 16:42:50 -0800 Subject: [PATCH 18/21] Potential fix for pull request finding 'Nested 'if' statements can be combined' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../ReleaseNotesService.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs index af1ced2a0..296f450bf 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs @@ -95,13 +95,10 @@ Cancel ctx // Validate lifecycle values in products foreach (var product in input.Products) { - if (!string.IsNullOrWhiteSpace(product.Lifecycle)) + if (!string.IsNullOrWhiteSpace(product.Lifecycle) && !config.AvailableLifecycles.Contains(product.Lifecycle)) { - if (!config.AvailableLifecycles.Contains(product.Lifecycle)) - { - collector.EmitError(string.Empty, $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", config.AvailableLifecycles)}"); - return false; - } + collector.EmitError(string.Empty, $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", config.AvailableLifecycles)}"); + return false; } } From 9ce044217373f6312dc72df2510b9d23664de07f Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 16:34:02 -0800 Subject: [PATCH 19/21] Rename files --- .../ChangelogConfiguration.cs | 2 +- .../ChangelogData.cs} | 7 +++-- .../ChangelogInput.cs} | 6 ++-- .../ChangelogYamlStaticContext.cs} | 6 ++-- ...aseNotesService.cs => ChangelogService.cs} | 31 ++++++++++--------- .../Arguments/ProductInfoParser.cs | 2 +- .../docs-builder/Commands/ChangelogCommand.cs | 8 ++--- 7 files changed, 32 insertions(+), 30 deletions(-) rename src/services/Elastic.Documentation.Services/{ReleaseNotes => Changelog}/ChangelogConfiguration.cs (94%) rename src/services/Elastic.Documentation.Services/{ReleaseNotes/ReleaseNotesData.cs => Changelog/ChangelogData.cs} (85%) rename src/services/Elastic.Documentation.Services/{ReleaseNotes/ReleaseNotesInput.cs => Changelog/ChangelogInput.cs} (84%) rename src/services/Elastic.Documentation.Services/{ReleaseNotes/ReleaseNotesYamlStaticContext.cs => Changelog/ChangelogYamlStaticContext.cs} (69%) rename src/services/Elastic.Documentation.Services/{ReleaseNotesService.cs => ChangelogService.cs} (90%) diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs similarity index 94% rename from src/services/Elastic.Documentation.Services/ReleaseNotes/ChangelogConfiguration.cs rename to src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs index d239254f2..fd57b1615 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ChangelogConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs @@ -2,7 +2,7 @@ // Elasticsearch B.V licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information -namespace Elastic.Documentation.Services.ReleaseNotes; +namespace Elastic.Documentation.Services.Changelog; /// /// Configuration for changelog generation diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs similarity index 85% rename from src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs rename to src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs index 9cbc0d39d..76b57504b 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesData.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs @@ -2,12 +2,12 @@ // 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 -namespace Elastic.Documentation.Services.ReleaseNotes; +namespace Elastic.Documentation.Services.Changelog; /// -/// Data structure for release notes YAML file matching the exact schema +/// Data structure for changelog YAML file matching the exact schema /// -public class ReleaseNotesData +public class ChangelogData { // Automated fields public string? Pr { get; set; } @@ -32,3 +32,4 @@ public class ProductInfo public string? Target { get; set; } public string? Lifecycle { get; set; } } + diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs similarity index 84% rename from src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs rename to src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs index d741b520f..a3d943680 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesInput.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs @@ -2,12 +2,12 @@ // 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 -namespace Elastic.Documentation.Services.ReleaseNotes; +namespace Elastic.Documentation.Services.Changelog; /// -/// Input data for creating a release notes changelog fragment +/// Input data for creating a changelog fragment /// -public class ReleaseNotesInput +public class ChangelogInput { public required string Title { get; set; } public required string Type { get; set; } diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs similarity index 69% rename from src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs rename to src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs index 79b481003..6aa2b85e8 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotes/ReleaseNotesYamlStaticContext.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs @@ -4,11 +4,11 @@ using YamlDotNet.Serialization; -namespace Elastic.Documentation.Services.ReleaseNotes; +namespace Elastic.Documentation.Services.Changelog; [YamlStaticContext] -[YamlSerializable(typeof(ReleaseNotesData))] +[YamlSerializable(typeof(ChangelogData))] [YamlSerializable(typeof(ProductInfo))] [YamlSerializable(typeof(ChangelogConfiguration))] -public partial class ReleaseNotesYamlStaticContext; +public partial class ChangelogYamlStaticContext; diff --git a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs similarity index 90% rename from src/services/Elastic.Documentation.Services/ReleaseNotesService.cs rename to src/services/Elastic.Documentation.Services/ChangelogService.cs index 296f450bf..c7c393358 100644 --- a/src/services/Elastic.Documentation.Services/ReleaseNotesService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -8,24 +8,24 @@ using System.Text; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; -using Elastic.Documentation.Services.ReleaseNotes; +using Elastic.Documentation.Services.Changelog; using Microsoft.Extensions.Logging; using YamlDotNet.Core; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; namespace Elastic.Documentation.Services; -public class ReleaseNotesService( +public class ChangelogService( ILoggerFactory logFactory, IConfigurationContext configurationContext ) : IService { - private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly ILogger _logger = logFactory.CreateLogger(); private readonly IFileSystem _fileSystem = new FileSystem(); - public async Task CreateReleaseNotes( + public async Task CreateChangelog( IDiagnosticsCollector collector, - ReleaseNotesInput input, + ChangelogInput input, Cancel ctx ) { @@ -102,11 +102,11 @@ Cancel ctx } } - // Build release notes data from input - var releaseNotesData = BuildReleaseNotesData(input); + // Build changelog data from input + var changelogData = BuildChangelogData(input); // Generate YAML file - var yamlContent = GenerateYaml(releaseNotesData, config); + var yamlContent = GenerateYaml(changelogData, config); // Determine output path var outputDir = input.Output ?? Directory.GetCurrentDirectory(); @@ -134,12 +134,12 @@ Cancel ctx } catch (IOException ioEx) { - collector.EmitError(string.Empty, $"IO error creating release notes: {ioEx.Message}", ioEx); + collector.EmitError(string.Empty, $"IO error creating changelog: {ioEx.Message}", ioEx); return false; } catch (UnauthorizedAccessException uaEx) { - collector.EmitError(string.Empty, $"Access denied creating release notes: {uaEx.Message}", uaEx); + collector.EmitError(string.Empty, $"Access denied creating changelog: {uaEx.Message}", uaEx); return false; } } @@ -164,7 +164,7 @@ Cancel ctx try { var yamlContent = await _fileSystem.File.ReadAllTextAsync(finalConfigPath, ctx); - var deserializer = new StaticDeserializerBuilder(new ReleaseNotesYamlStaticContext()) + var deserializer = new StaticDeserializerBuilder(new ChangelogYamlStaticContext()) .WithNamingConvention(UnderscoredNamingConvention.Instance) .Build(); @@ -188,9 +188,9 @@ Cancel ctx } } - private static ReleaseNotesData BuildReleaseNotesData(ReleaseNotesInput input) + private static ChangelogData BuildChangelogData(ChangelogInput input) { - var data = new ReleaseNotesData + var data = new ChangelogData { Title = input.Title, Type = input.Type, @@ -217,7 +217,7 @@ private static ReleaseNotesData BuildReleaseNotesData(ReleaseNotesInput input) return data; } - private string GenerateYaml(ReleaseNotesData data, ChangelogConfiguration config) + private string GenerateYaml(ChangelogData data, ChangelogConfiguration config) { // Ensure areas is null if empty to omit it from YAML if (data.Areas != null && data.Areas.Count == 0) @@ -227,7 +227,7 @@ private string GenerateYaml(ReleaseNotesData data, ChangelogConfiguration config if (data.Issues != null && data.Issues.Count == 0) data.Issues = null; - var serializer = new StaticSerializerBuilder(new ReleaseNotesYamlStaticContext()) + var serializer = new StaticSerializerBuilder(new ChangelogYamlStaticContext()) .WithNamingConvention(UnderscoredNamingConvention.Instance) .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) .Build(); @@ -297,3 +297,4 @@ private static string SanitizeFilename(string input) return sanitized; } } + diff --git a/src/tooling/docs-builder/Arguments/ProductInfoParser.cs b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs index 4cc7bb28c..db10dc169 100644 --- a/src/tooling/docs-builder/Arguments/ProductInfoParser.cs +++ b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs @@ -3,7 +3,7 @@ // See the LICENSE file in the project root for more information using ConsoleAppFramework; -using Elastic.Documentation.Services.ReleaseNotes; +using Elastic.Documentation.Services.Changelog; namespace Documentation.Builder.Arguments; diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index ff8272a31..16f09f978 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -7,7 +7,7 @@ using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services; -using Elastic.Documentation.Services.ReleaseNotes; +using Elastic.Documentation.Services.Changelog; using Microsoft.Extensions.Logging; namespace Documentation.Builder.Commands; @@ -67,9 +67,9 @@ public async Task Create( { await using var serviceInvoker = new ServiceInvoker(collector); - var service = new ReleaseNotesService(logFactory, configurationContext); + var service = new ChangelogService(logFactory, configurationContext); - var input = new ReleaseNotesInput + var input = new ChangelogInput { Title = title, Type = type, @@ -88,7 +88,7 @@ public async Task Create( }; serviceInvoker.AddCommand(service, input, - async static (s, collector, state, ctx) => await s.CreateReleaseNotes(collector, state, ctx) + async static (s, collector, state, ctx) => await s.CreateChangelog(collector, state, ctx) ); return await serviceInvoker.InvokeAsync(ctx); From 660847e4909597a9f7613e29b3a790eb3493cc24 Mon Sep 17 00:00:00 2001 From: lcawl Date: Wed, 3 Dec 2025 16:59:38 -0800 Subject: [PATCH 20/21] Use raw string literal --- .../ChangelogService.cs | 87 +++++++++---------- 1 file changed, 42 insertions(+), 45 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index c7c393358..1ebbcaebe 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -5,7 +5,6 @@ using System.Globalization; using System.IO.Abstractions; using System.Linq; -using System.Text; using Elastic.Documentation.Configuration; using Elastic.Documentation.Diagnostics; using Elastic.Documentation.Services.Changelog; @@ -234,50 +233,48 @@ private string GenerateYaml(ChangelogData data, ChangelogConfiguration config) var yaml = serializer.Serialize(data); - // Add schema comments - var sb = new StringBuilder(); - _ = sb.AppendLine("##### Automated fields #####"); - _ = sb.AppendLine(); - _ = sb.AppendLine("# These fields are likely generated when the changelog is created and unlikely to require edits"); - _ = sb.AppendLine(); - _ = sb.AppendLine("# pr: An optional string that contains the pull request number"); - _ = sb.AppendLine("# issues: An optional array of strings that contain URLs for issues that are relevant to the PR"); - _ = sb.AppendLine("# type: A required string that contains the type of change"); - _ = sb.AppendLine("# It can be one of:"); - foreach (var type in config.AvailableTypes) - { - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"# - {type}"); - } - _ = sb.AppendLine("# subtype: An optional string that applies only to breaking changes"); - if (config.AvailableSubtypes.Count > 0) - { - _ = sb.AppendLine("# It can be one of:"); - foreach (var subtype in config.AvailableSubtypes) - { - _ = sb.AppendLine(CultureInfo.InvariantCulture, $"# - {subtype}"); - } - } - _ = sb.AppendLine("# products: A required array of objects that denote the affected products"); - _ = sb.AppendLine("# Each product object contains:"); - _ = sb.AppendLine("# - product: A required string with a predefined product ID"); - _ = sb.AppendLine("# - target: An optional string with the target version or date"); - _ = sb.AppendLine("# - lifecycle: An optional string (preview, beta, ga)"); - _ = sb.AppendLine("# areas: An optional array of strings that denotes the parts/components/services affected"); - _ = sb.AppendLine(); - _ = sb.AppendLine("##### Non-automated fields #####"); - _ = sb.AppendLine(); - _ = sb.AppendLine("# These fields might be generated when the changelog is created but are likely to require edits"); - _ = sb.AppendLine(); - _ = sb.AppendLine("# title: A required string that is a short, user-facing headline (Max 80 characters)"); - _ = sb.AppendLine("# description: An optional string that provides additional information (Max 600 characters)"); - _ = sb.AppendLine("# impact: An optional string that describes how the user's environment is affected"); - _ = sb.AppendLine("# action: An optional string that describes what users must do to mitigate"); - _ = sb.AppendLine("# feature-id: An optional string to associate with a unique feature flag"); - _ = sb.AppendLine("# highlight: An optional boolean for items that should be included in release highlights"); - _ = sb.AppendLine(); - _ = sb.Append(yaml); - - return sb.ToString(); + // Build types list + var typesList = string.Join("\n", config.AvailableTypes.Select(t => $"# - {t}")); + + // Build subtypes list + var subtypesList = config.AvailableSubtypes.Count > 0 + ? "\n# It can be one of:\n" + string.Join("\n", config.AvailableSubtypes.Select(s => $"# - {s}")) + : string.Empty; + + // Add schema comments using raw string literal + var result = $""" + ##### Automated fields ##### + + # These fields are likely generated when the changelog is created and unlikely to require edits + + # pr: An optional string that contains the pull request number + # issues: An optional array of strings that contain URLs for issues that are relevant to the PR + # type: A required string that contains the type of change + # It can be one of: + {typesList} + # subtype: An optional string that applies only to breaking changes{subtypesList} + # products: A required array of objects that denote the affected products + # Each product object contains: + # - product: A required string with a predefined product ID + # - target: An optional string with the target version or date + # - lifecycle: An optional string (preview, beta, ga) + # areas: An optional array of strings that denotes the parts/components/services affected + + ##### Non-automated fields ##### + + # These fields might be generated when the changelog is created but are likely to require edits + + # title: A required string that is a short, user-facing headline (Max 80 characters) + # description: An optional string that provides additional information (Max 600 characters) + # impact: An optional string that describes how the user's environment is affected + # action: An optional string that describes what users must do to mitigate + # feature-id: An optional string to associate with a unique feature flag + # highlight: An optional boolean for items that should be included in release highlights + + {yaml} + """; + + return result; } private static string SanitizeFilename(string input) From ec4157eb95c1b9c019d8a724501eeb2ca2316c78 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Wed, 3 Dec 2025 17:11:31 -0800 Subject: [PATCH 21/21] Potential fix for pull request finding 'Missed opportunity to use Where' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Elastic.Documentation.Services/ChangelogService.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 1ebbcaebe..bf713c943 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -92,13 +92,10 @@ Cancel ctx } // Validate lifecycle values in products - foreach (var product in input.Products) + foreach (var product in input.Products.Where(product => !string.IsNullOrWhiteSpace(product.Lifecycle) && !config.AvailableLifecycles.Contains(product.Lifecycle))) { - if (!string.IsNullOrWhiteSpace(product.Lifecycle) && !config.AvailableLifecycles.Contains(product.Lifecycle)) - { - collector.EmitError(string.Empty, $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", config.AvailableLifecycles)}"); - return false; - } + collector.EmitError(string.Empty, $"Lifecycle '{product.Lifecycle}' for product '{product.Product}' is not in the list of available lifecycles. Available lifecycles: {string.Join(", ", config.AvailableLifecycles)}"); + return false; } // Build changelog data from input