diff --git a/config/changelog.yml.example b/config/changelog.yml.example new file mode 100644 index 000000000..47abf4266 --- /dev/null +++ b/config/changelog.yml.example @@ -0,0 +1,56 @@ +# 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 + +# 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/Changelog/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs new file mode 100644 index 000000000..fd57b1615 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.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 + +namespace Elastic.Documentation.Services.Changelog; + +/// +/// Configuration for changelog generation +/// +public class ChangelogConfiguration +{ + 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 List? AvailableAreas { get; set; } + + public List? AvailableProducts { get; set; } + + public static ChangelogConfiguration Default => new(); +} + diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.cs new file mode 100644 index 000000000..76b57504b --- /dev/null +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogData.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.Changelog; + +/// +/// Data structure for changelog YAML file matching the exact schema +/// +public class ChangelogData +{ + // Automated fields + 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/Changelog/ChangelogInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs new file mode 100644 index 000000000..a3d943680 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs @@ -0,0 +1,27 @@ +// 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.Changelog; + +/// +/// Input data for creating a changelog fragment +/// +public class ChangelogInput +{ + public required string Title { get; set; } + public required string Type { get; set; } + public required List 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? Output { get; set; } + public string? Config { get; set; } +} + diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs new file mode 100644 index 000000000..6aa2b85e8 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogYamlStaticContext.cs @@ -0,0 +1,14 @@ +// 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.Changelog; + +[YamlStaticContext] +[YamlSerializable(typeof(ChangelogData))] +[YamlSerializable(typeof(ProductInfo))] +[YamlSerializable(typeof(ChangelogConfiguration))] +public partial class ChangelogYamlStaticContext; + diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs new file mode 100644 index 000000000..bf713c943 --- /dev/null +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -0,0 +1,294 @@ +// 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.Linq; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Diagnostics; +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 ChangelogService( + ILoggerFactory logFactory, + IConfigurationContext configurationContext +) : IService +{ + private readonly ILogger _logger = logFactory.CreateLogger(); + private readonly IFileSystem _fileSystem = new FileSystem(); + + public async Task CreateChangelog( + IDiagnosticsCollector collector, + ChangelogInput input, + Cancel ctx + ) + { + try + { + // Load changelog configuration + var config = await LoadChangelogConfiguration(collector, input.Config, ctx); + if (config == null) + { + collector.EmitError(string.Empty, "Failed to load changelog 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.Count == 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.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) && !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 + if (config.AvailableAreas != null && config.AvailableAreas.Count > 0) + { + foreach (var area in input.Areas.Where(area => !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.Where(p => !config.AvailableProducts.Contains(p.Product))) + { + collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products. Available products: {string.Join(", ", config.AvailableProducts)}"); + return false; + } + } + + // Validate lifecycle values in products + foreach (var product in input.Products.Where(product => !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; + } + + // Build changelog data from input + var changelogData = BuildChangelogData(input); + + // Generate YAML file + var yamlContent = GenerateYaml(changelogData, 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 = _fileSystem.Path.Combine(outputDir, filename); + + // Write file + await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); + _logger.LogInformation("Created changelog fragment: {FilePath}", filePath); + + return true; + } + catch (OperationCanceledException) + { + // If cancelled, don't emit error; propagate cancellation signal. + throw; + } + catch (IOException ioEx) + { + collector.EmitError(string.Empty, $"IO error creating changelog: {ioEx.Message}", ioEx); + return false; + } + catch (UnauthorizedAccessException uaEx) + { + collector.EmitError(string.Empty, $"Access denied creating changelog: {uaEx.Message}", uaEx); + return false; + } + } + + private async Task LoadChangelogConfiguration( + IDiagnosticsCollector collector, + string? configPath, + Cancel ctx + ) + { + // Determine config file path + _ = configurationContext; // Suppress unused warning - kept for future extensibility + var finalConfigPath = configPath ?? _fileSystem.Path.Combine(Directory.GetCurrentDirectory(), "docs", "changelog.yml"); + + if (!_fileSystem.File.Exists(finalConfigPath)) + { + // Use default configuration if file doesn't exist + _logger.LogWarning("Changelog configuration not found at {ConfigPath}, using defaults", finalConfigPath); + return ChangelogConfiguration.Default; + } + + try + { + var yamlContent = await _fileSystem.File.ReadAllTextAsync(finalConfigPath, ctx); + var deserializer = new StaticDeserializerBuilder(new ChangelogYamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .Build(); + + var config = deserializer.Deserialize(yamlContent); + return config; + } + catch (IOException ex) + { + collector.EmitError(finalConfigPath, $"I/O error loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (UnauthorizedAccessException ex) + { + collector.EmitError(finalConfigPath, $"Access denied loading changelog configuration: {ex.Message}", ex); + return null; + } + catch (YamlException ex) + { + collector.EmitError(finalConfigPath, $"YAML parsing error in changelog configuration: {ex.Message}", ex); + return null; + } + } + + private static ChangelogData BuildChangelogData(ChangelogInput input) + { + var data = new ChangelogData + { + 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 + }; + + 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(ChangelogData data, ChangelogConfiguration 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 ChangelogYamlStaticContext()) + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) + .Build(); + + var yaml = serializer.Serialize(data); + + // 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) + { + 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/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/tooling/docs-builder/Arguments/ProductInfoParser.cs b/src/tooling/docs-builder/Arguments/ProductInfoParser.cs new file mode 100644 index 000000000..db10dc169 --- /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.Changelog; + +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/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs new file mode 100644 index 000000000..16f09f978 --- /dev/null +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -0,0 +1,97 @@ +// 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 Documentation.Builder.Arguments; +using Elastic.Documentation.Configuration; +using Elastic.Documentation.Diagnostics; +using Elastic.Documentation.Services; +using Elastic.Documentation.Services.Changelog; +using Microsoft.Extensions.Logging; + +namespace Documentation.Builder.Commands; + +internal sealed class ChangelogCommand( + ILoggerFactory logFactory, + IDiagnosticsCollector collector, + IConfigurationContext configurationContext +) +{ + /// + /// Changelog commands. Use 'changelog add' to create a new changelog fragment. + /// + [Command("")] + public Task Default() + { + 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); + } + + /// + /// 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.) + /// 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 + /// 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: 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( + string title, + string type, + [ProductInfoParser] List products, + string? subtype = null, + string[]? areas = null, + string? pr = null, + string[]? issues = null, + string? description = null, + string? impact = null, + string? action = null, + string? featureId = null, + bool? highlight = null, + string? output = null, + string? config = null, + Cancel ctx = default + ) + { + await using var serviceInvoker = new ServiceInvoker(collector); + + var service = new ChangelogService(logFactory, configurationContext); + + var input = new ChangelogInput + { + Title = title, + Type = type, + Products = products, + Subtype = subtype, + Areas = areas ?? [], + Pr = pr, + Issues = issues ?? [], + Description = description, + Impact = impact, + Action = action, + FeatureId = featureId, + Highlight = highlight, + Output = output, + Config = config + }; + + serviceInvoker.AddCommand(service, input, + async static (s, collector, state, ctx) => await s.CreateChangelog(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..fe06e63f7 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("changelog"); //assembler commands