From ec47686c4cb36c5a6ce1838d25cbffd309d0f7db Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 09:00:19 -0800 Subject: [PATCH 01/21] Add product label blockers to changelog creation --- config/changelog.yml.example | 12 + docs/contribute/changelog.md | 57 ++- .../Changelog/ChangelogConfiguration.cs | 6 + .../Changelog/ChangelogInput.cs | 2 +- .../ChangelogService.cs | 405 +++++++++++------- .../docs-builder/Commands/ChangelogCommand.cs | 17 +- .../ChangelogServiceTests.cs | 298 ++++++++++++- 7 files changed, 631 insertions(+), 166 deletions(-) diff --git a/config/changelog.yml.example b/config/changelog.yml.example index 5d44b20ba..4a494edf9 100644 --- a/config/changelog.yml.example +++ b/config/changelog.yml.example @@ -76,3 +76,15 @@ label_to_areas: # "area:index": index-management # "area:multiple": "search, security" # Multiple areas comma-separated +# Product-specific label blockers (optional) +# Maps product IDs to lists of labels that prevent changelog creation for that product +# If you run the changelog add command with the --prs option and a PR has any of these labels, the changelog is not created +product_label_blockers: + # Example: Skip changelog for cloud.serverless product when PR has "Watcher" label + # cloud-serverless: + # - ":Data Management/Watcher" + # - ">non-issue" + # Example: Skip changelog creation for elasticsearch product when PR has "skip:releaseNotes" label + # elasticsearch: + # - ">non-issue" + diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 31024aa2a..41a880ff6 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -26,13 +26,13 @@ Add a new changelog fragment from command-line input Options: --products > Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05") [Required] - --title Optional: A short, user-facing title (max 80 characters). Required if --pr is not specified. If --pr and --title are specified, the latter value is used instead of what exists in the PR. [Default: null] - --type Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if --pr is not specified. If mappings are configured, type can be derived from the PR. [Default: null] + --title Optional: A short, user-facing title (max 80 characters). Required if --prs is not specified. If --prs and --title are specified, the latter value is used instead of what exists in the PR. [Default: null] + --type Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if --prs is not specified. If mappings are configured, type can be derived from the PR. [Default: null] --subtype Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) [Default: null] --areas Optional: Area(s) affected (comma-separated or specify multiple times) [Default: null] - --pr Optional: Pull request URL or PR number (if --owner and --repo are provided). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. [Default: null] - --owner Optional: GitHub repository owner (used when --pr is just a number) [Default: null] - --repo Optional: GitHub repository name (used when --pr is just a number) [Default: null] + --prs Optional: Pull request URL(s) or PR number(s) (comma-separated, or if --owner and --repo are provided, just numbers). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. [Default: null] + --owner Optional: GitHub repository owner (used when --prs contains just numbers) [Default: null] + --repo Optional: GitHub repository name (used when --prs contains just numbers) [Default: null] --issues Optional: Issue URL(s) (comma-separated or specify multiple times) [Default: null] --description Optional: Additional information about the change (max 600 characters) [Default: null] --impact Optional: How the user's environment is affected [Default: null] @@ -82,7 +82,7 @@ If a configuration file exists, the command validates all its values before gene ### GitHub label mappings You can optionally add `label_to_type` and `label_to_areas` mappings in your changelog configuration. -When you run the command with the `--pr` option, it can use these mappings to fill in the `type` and `areas` in your changelog based on your pull request labels. +When you run the command with the `--prs` option, it can use these mappings to fill in the `type` and `areas` in your changelog based on your pull request labels. Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). @@ -120,13 +120,13 @@ docs-builder changelog add \ --type bug-fix \ <2> --products "elasticsearch 9.2.3, cloud-serverless 2025-12-02" \ <3> --areas "ES|QL" - --pr "https://github.com/elastic/elasticsearch/pull/137431" <4> + --prs "https://github.com/elastic/elasticsearch/pull/137431" <4> ``` 1. This option is required only if you want to override what's derived from the PR title. 2. The type values are defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). 3. The product values are defined in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). -4. The `--pr` value can be a full URL (such as `https://github.com/owner/repo/pull/123`, a short format (such as `owner/repo#123`) or just a number (in which case you must also provide `--owner` and `--repo` options). +4. The `--prs` value can be a full URL (such as `https://github.com/owner/repo/pull/123`, a short format (such as `owner/repo#123`) or just a number (in which case you must also provide `--owner` and `--repo` options). Multiple PRs can be provided comma-separated, and one changelog file will be created for each PR. The output file has the following format: @@ -158,7 +158,7 @@ available_areas: - ES|QL # Add more areas as needed -# GitHub label mappings (optional - used when --pr option is specified) +# GitHub label mappings (optional - used when --prs option is specified) # Maps GitHub PR labels to changelog type values # When a PR has a label that matches a key, the corresponding type value is used label_to_type: @@ -171,12 +171,24 @@ label_to_type: label_to_areas: # Example mappings - customize based on your label naming conventions ":Search Relevance/ES|QL": "ES|QL" + +# Product-specific label blockers (optional) +# Maps product IDs to lists of labels that prevent changelog creation for that product +# If a PR has any of these labels for the specified product, changelog creation will be skipped +product_label_blockers: + # Example: Skip changelog creation for cloud.serverless when PR has "ILM" label + cloud-serverless: + - "ILM" + - "skip:releaseNotes" + # Example: Skip changelog creation for elasticsearch when PR has "skip:releaseNotes" label + elasticsearch: + - "skip:releaseNotes" ``` -When you use the `--pr` option to derive information from a pull request, it can make use of those mappings: +When you use the `--prs` option to derive information from a pull request, it can make use of those mappings: ```sh -docs-builder changelog add --pr https://github.com/elastic/elasticsearch/pull/139272 --products "elasticsearch 9.3.0" --config test/changelog.yml +docs-builder changelog add --prs https://github.com/elastic/elasticsearch/pull/139272 --products "elasticsearch 9.3.0" --config test/changelog.yml ``` In this case, the changelog file derives the title, type, and areas: @@ -191,3 +203,26 @@ areas: - ES|QL title: '[ES|QL] Take TOP_SNIPPETS out of snapshot' ``` + +### Product-specific label blockers + +You can configure product-specific label blockers to prevent changelog creation for certain PRs based on their labels. + +When using `--prs` with multiple PRs, if a PR has a blocking label for any of the specified products, that PR will be skipped and no changelog file will be created for it. A warning message will be emitted indicating which PR was skipped and why. + +For example, if you configure: + +```yaml +product_label_blockers: + cloud-serverless: + - "ILM" + - "skip:releaseNotes" +``` + +And run: + +```sh +docs-builder changelog add --prs "1234, 5678" --products "cloud-serverless 2025-08-05" --owner elastic --repo elasticsearch +``` + +If PR 1234 has the "ILM" label, it will be skipped and no changelog will be created for it. If PR 5678 does not have any blocking labels, a changelog will be created for it. diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs index 90f08db0e..bdead3c0b 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs @@ -57,6 +57,12 @@ public class ChangelogConfiguration /// public Dictionary? LabelToAreas { get; set; } + /// + /// Product-specific label blocking configuration + /// Maps product IDs to lists of labels that should prevent changelog creation for that product + /// + public Dictionary>? ProductLabelBlockers { get; set; } + public static ChangelogConfiguration Default => new(); } diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs index 59f109a37..0d1c0f738 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogInput.cs @@ -14,7 +14,7 @@ public class ChangelogInput public required List Products { get; set; } public string? Subtype { get; set; } public string[] Areas { get; set; } = []; - public string? Pr { get; set; } + public string[]? Prs { get; set; } public string? Owner { get; set; } public string? Repo { get; set; } public string[] Issues { get; set; } = []; diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 67d071994..940a64ea3 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -40,207 +40,320 @@ Cancel ctx return false; } - // Validate that if PR is just a number, owner and repo must be provided - if (!string.IsNullOrWhiteSpace(input.Pr) - && int.TryParse(input.Pr, out _) - && (string.IsNullOrWhiteSpace(input.Owner) || string.IsNullOrWhiteSpace(input.Repo))) + // Handle multiple PRs if provided + if (input.Prs != null && input.Prs.Length > 0) { - collector.EmitError(string.Empty, "When --pr is specified as just a number, both --owner and --repo must be provided"); - return false; + return await CreateChangelogsForMultiplePrs(collector, input, config, ctx); } - // Validate that if --use-pr-number is set, PR must be provided - if (input.UsePrNumber && string.IsNullOrWhiteSpace(input.Pr)) + // Single PR or no PR - use existing logic + return await CreateSingleChangelog(collector, input, config, ctx); + } + 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 CreateChangelogsForMultiplePrs( + IDiagnosticsCollector collector, + ChangelogInput input, + ChangelogConfiguration config, + Cancel ctx + ) + { + if (input.Prs == null || input.Prs.Length == 0) + { + return false; + } + + // Validate that if PRs are just numbers, owner and repo must be provided + var allAreNumbers = input.Prs.All(pr => int.TryParse(pr.Trim(), out _)); + if (allAreNumbers && (string.IsNullOrWhiteSpace(input.Owner) || string.IsNullOrWhiteSpace(input.Repo))) + { + collector.EmitError(string.Empty, "When --prs contains only numbers, both --owner and --repo must be provided"); + return false; + } + + var successCount = 0; + var skippedCount = 0; + + foreach (var pr in input.Prs) + { + var prTrimmed = pr.Trim(); + if (string.IsNullOrWhiteSpace(prTrimmed)) + continue; + + // Fetch PR information + var prInfo = await TryFetchPrInfoAsync(prTrimmed, input.Owner, input.Repo, ctx); + if (prInfo == null) { - collector.EmitError(string.Empty, "When --use-pr-number is specified, --pr must also be provided"); - return false; + collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prTrimmed}. Skipping this PR."); + continue; } - // If PR is specified, try to fetch PR information and derive title/type - if (!string.IsNullOrWhiteSpace(input.Pr)) + // Check for label blockers + var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prTrimmed); + if (shouldSkip) { - var prInfo = await TryFetchPrInfoAsync(input.Pr, input.Owner, input.Repo, ctx); - if (prInfo == null) - { - collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {input.Pr}. Cannot derive title and type."); - return false; - } + skippedCount++; + continue; + } - // Use PR title if title was not explicitly provided - if (string.IsNullOrWhiteSpace(input.Title)) - { - if (string.IsNullOrWhiteSpace(prInfo.Title)) - { - collector.EmitError(string.Empty, $"PR {input.Pr} does not have a title. Please provide --title or ensure the PR has a title."); - return false; - } - input.Title = prInfo.Title; - _logger.LogInformation("Using PR title: {Title}", input.Title); - } - else - { - _logger.LogDebug("Using explicitly provided title, ignoring PR title"); - } + // Create a copy of input for this PR + var prInput = new ChangelogInput + { + Title = input.Title, + Type = input.Type, + Products = input.Products, + Subtype = input.Subtype, + Areas = input.Areas, + Prs = [prTrimmed], + Owner = input.Owner, + Repo = input.Repo, + Issues = input.Issues, + Description = input.Description, + Impact = input.Impact, + Action = input.Action, + FeatureId = input.FeatureId, + Highlight = input.Highlight, + Output = input.Output, + Config = input.Config + }; + + // Process this PR (treat as single PR) + var result = await CreateSingleChangelog(collector, prInput, config, ctx); + if (result) + { + successCount++; + } + } - // Map labels to type if type was not explicitly provided - if (string.IsNullOrWhiteSpace(input.Type)) - { - if (config.LabelToType == null || config.LabelToType.Count == 0) - { - collector.EmitError(string.Empty, $"Cannot derive type from PR {input.Pr} labels: no label-to-type mapping configured in changelog.yml. Please provide --type or configure label_to_type in changelog.yml."); - return false; - } + if (successCount == 0 && skippedCount == 0) + { + return false; + } - var mappedType = MapLabelsToType(prInfo.Labels, config.LabelToType); - if (mappedType == null) - { - var availableLabels = prInfo.Labels.Length > 0 ? string.Join(", ", prInfo.Labels) : "none"; - collector.EmitError(string.Empty, $"Cannot derive type from PR {input.Pr} labels ({availableLabels}). No matching label found in label_to_type mapping. Please provide --type or add a label mapping in changelog.yml."); - return false; - } - input.Type = mappedType; - _logger.LogInformation("Mapped PR labels to type: {Type}", input.Type); - } - else - { - _logger.LogDebug("Using explicitly provided type, ignoring PR labels"); - } + _logger.LogInformation("Processed {SuccessCount} PR(s) successfully, skipped {SkippedCount} PR(s)", successCount, skippedCount); + return successCount > 0; + } + + private bool ShouldSkipPrDueToLabelBlockers( + string[] prLabels, + List products, + ChangelogConfiguration config, + IDiagnosticsCollector collector, + string prUrl + ) + { + if (config.ProductLabelBlockers == null || config.ProductLabelBlockers.Count == 0) + { + return false; + } - // Map labels to areas if areas were not explicitly provided - if ((input.Areas == null || input.Areas.Length == 0) && config.LabelToAreas != null) + foreach (var product in products) + { + var normalizedProductId = product.Product.Replace('_', '-'); + if (config.ProductLabelBlockers.TryGetValue(normalizedProductId, out var blockerLabels)) + { + foreach (var blockerLabel in blockerLabels) { - var mappedAreas = MapLabelsToAreas(prInfo.Labels, config.LabelToAreas); - if (mappedAreas.Count > 0) + if (prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)) { - input.Areas = mappedAreas.ToArray(); - _logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas)); + collector.EmitWarning(string.Empty, $"Skipping changelog creation for PR {prUrl} due to blocking label '{blockerLabel}' for product '{product.Product}'. This label is configured to prevent changelog creation for this product."); + return true; } } - else if (input.Areas != null && input.Areas.Length > 0) - { - _logger.LogDebug("Using explicitly provided areas, ignoring PR labels"); - } } + } - // Validate required fields (must be provided either explicitly or derived from PR) - if (string.IsNullOrWhiteSpace(input.Title)) - { - collector.EmitError(string.Empty, "Title is required. Provide --title or specify --pr to derive it from the PR."); - return false; - } + return false; + } - if (string.IsNullOrWhiteSpace(input.Type)) - { - collector.EmitError(string.Empty, "Type is required. Provide --type or specify --pr to derive it from PR labels (requires label_to_type mapping in changelog.yml)."); - return false; - } + private async Task CreateSingleChangelog( + IDiagnosticsCollector collector, + ChangelogInput input, + ChangelogConfiguration config, + Cancel ctx + ) + { + // Get the PR URL if Prs is provided (for single PR processing) + var prUrl = input.Prs != null && input.Prs.Length > 0 ? input.Prs[0] : null; - if (input.Products.Count == 0) + // Validate that if PR is just a number, owner and repo must be provided + if (!string.IsNullOrWhiteSpace(prUrl) + && int.TryParse(prUrl, out _) + && (string.IsNullOrWhiteSpace(input.Owner) || string.IsNullOrWhiteSpace(input.Repo))) + { + collector.EmitError(string.Empty, "When --prs is specified as just a number, both --owner and --repo must be provided"); + return false; + } + + // If PR is specified, try to fetch PR information and derive title/type + if (!string.IsNullOrWhiteSpace(prUrl)) + { + var prInfo = await TryFetchPrInfoAsync(prUrl, input.Owner, input.Repo, ctx); + if (prInfo == null) { - collector.EmitError(string.Empty, "At least one product is required"); + collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Cannot derive title and type."); return false; } - // Validate type is in allowed list - if (!config.AvailableTypes.Contains(input.Type)) + // Use PR title if title was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Title)) { - collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); - return false; + if (string.IsNullOrWhiteSpace(prInfo.Title)) + { + collector.EmitError(string.Empty, $"PR {prUrl} does not have a title. Please provide --title or ensure the PR has a title."); + return false; + } + input.Title = prInfo.Title; + _logger.LogInformation("Using PR title: {Title}", input.Title); } - - // Validate subtype if provided - if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.AvailableSubtypes.Contains(input.Subtype)) + else { - collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); - return false; + _logger.LogDebug("Using explicitly provided title, ignoring PR title"); } - // Validate areas if configuration provides available areas - if (config.AvailableAreas != null && config.AvailableAreas.Count > 0 && input.Areas != null) + // Map labels to type if type was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Type)) { - foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area))) + if (config.LabelToType == null || config.LabelToType.Count == 0) { - collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}"); + collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels: no label-to-type mapping configured in changelog.yml. Please provide --type or configure label_to_type in changelog.yml."); return false; } - } - // Always validate products against products.yml - var validProductIds = configurationContext.ProductsConfiguration.Products.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var product in input.Products) - { - // Normalize product ID (replace underscores with hyphens for comparison) - var normalizedProductId = product.Product.Replace('_', '-'); - if (!validProductIds.Contains(normalizedProductId)) + var mappedType = MapLabelsToType(prInfo.Labels, config.LabelToType); + if (mappedType == null) { - var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + var availableLabels = prInfo.Labels.Length > 0 ? string.Join(", ", prInfo.Labels) : "none"; + collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels ({availableLabels}). No matching label found in label_to_type mapping. Please provide --type or add a label mapping in changelog.yml."); return false; } + input.Type = mappedType; + _logger.LogInformation("Mapped PR labels to type: {Type}", input.Type); } - - // 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)) + else { - _ = _fileSystem.Directory.CreateDirectory(outputDir); + _logger.LogDebug("Using explicitly provided type, ignoring PR labels"); } - // Generate filename - string filename; - if (input.UsePrNumber) + // Map labels to areas if areas were not explicitly provided + if ((input.Areas == null || input.Areas.Length == 0) && config.LabelToAreas != null) { - var prNumber = ExtractPrNumber(input.Pr!, input.Owner, input.Repo); - if (prNumber == null) + var mappedAreas = MapLabelsToAreas(prInfo.Labels, config.LabelToAreas); + if (mappedAreas.Count > 0) { - collector.EmitError(string.Empty, $"Unable to extract PR number from '{input.Pr}'. Cannot use --use-pr-number option."); - return false; + input.Areas = mappedAreas.ToArray(); + _logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas)); } - filename = $"{prNumber}.yaml"; } - else + else if (input.Areas != null && input.Areas.Length > 0) { - // Generate filename (timestamp-slug.yaml) - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var slug = SanitizeFilename(input.Title); - filename = $"{timestamp}-{slug}.yaml"; + _logger.LogDebug("Using explicitly provided areas, ignoring PR labels"); } - var filePath = _fileSystem.Path.Combine(outputDir, filename); + } + + // Validate required fields (must be provided either explicitly or derived from PR) + if (string.IsNullOrWhiteSpace(input.Title)) + { + collector.EmitError(string.Empty, "Title is required. Provide --title or specify --prs to derive it from the PR."); + return false; + } - // Write file - await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); - _logger.LogInformation("Created changelog fragment: {FilePath}", filePath); + if (string.IsNullOrWhiteSpace(input.Type)) + { + collector.EmitError(string.Empty, "Type is required. Provide --type or specify --prs to derive it from PR labels (requires label_to_type mapping in changelog.yml)."); + return false; + } - return true; + if (input.Products.Count == 0) + { + collector.EmitError(string.Empty, "At least one product is required"); + return false; } - catch (OperationCanceledException) + + // Validate type is in allowed list + if (!config.AvailableTypes.Contains(input.Type)) { - // If cancelled, don't emit error; propagate cancellation signal. - throw; + collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); + return false; } - catch (IOException ioEx) + + // Validate subtype if provided + if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.AvailableSubtypes.Contains(input.Subtype)) { - collector.EmitError(string.Empty, $"IO error creating changelog: {ioEx.Message}", ioEx); + collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); return false; } - catch (UnauthorizedAccessException uaEx) + + // Validate areas if configuration provides available areas + if (config.AvailableAreas != null && config.AvailableAreas.Count > 0 && input.Areas != null) { - collector.EmitError(string.Empty, $"Access denied creating changelog: {uaEx.Message}", uaEx); + 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; + } + } + + // Always validate products against products.yml + var validProductIds = configurationContext.ProductsConfiguration.Products.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); + foreach (var product in input.Products) + { + // Normalize product ID (replace underscores with hyphens for comparison) + var normalizedProductId = product.Product.Replace('_', '-'); + if (!validProductIds.Contains(normalizedProductId)) + { + var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); + collector.EmitError(string.Empty, $"Product '{product.Product}' is not in the list of available products from config/products.yml. Available products: {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, prUrl); + + // 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; } private async Task LoadChangelogConfiguration( @@ -327,7 +440,7 @@ Cancel ctx } } - private static ChangelogData BuildChangelogData(ChangelogInput input) + private static ChangelogData BuildChangelogData(ChangelogInput input, string? prUrl = null) { // Title and Type are guaranteed to be non-null at this point due to validation above var data = new ChangelogData @@ -340,7 +453,7 @@ private static ChangelogData BuildChangelogData(ChangelogInput input) Action = input.Action, FeatureId = input.FeatureId, Highlight = input.Highlight, - Pr = input.Pr, + Pr = prUrl ?? (input.Prs != null && input.Prs.Length > 0 ? input.Prs[0] : null), Products = input.Products }; diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 335ccf0c0..c6c152d65 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -36,9 +36,9 @@ public Task Default() /// 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 or PR number (if --owner and --repo are provided). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. - /// Optional: GitHub repository owner (used when --pr is just a number) - /// Optional: GitHub repository name (used when --pr is just a number) + /// Optional: Pull request URL(s) or PR number(s) (comma-separated, or if --owner and --repo are provided, just numbers). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. + /// Optional: GitHub repository owner (used when --prs contains just numbers) + /// Optional: GitHub repository name (used when --prs contains just numbers) /// 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 @@ -56,7 +56,7 @@ public async Task Create( string? type = null, string? subtype = null, string[]? areas = null, - string? pr = null, + string[]? prs = null, string? owner = null, string? repo = null, string[]? issues = null, @@ -76,6 +76,13 @@ public async Task Create( IGitHubPrService githubPrService = new GitHubPrService(logFactory); var service = new ChangelogService(logFactory, configurationContext, githubPrService); + // Parse comma-separated PRs if provided as a single string + string[]? parsedPrs = null; + if (prs != null && prs.Length > 0) + { + parsedPrs = prs.SelectMany(pr => pr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).ToArray(); + } + var input = new ChangelogInput { Title = title, @@ -83,7 +90,7 @@ public async Task Create( Products = products, Subtype = subtype, Areas = areas ?? [], - Pr = pr, + Prs = parsedPrs, Owner = owner, Repo = repo, Issues = issues ?? [], diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index d5cdf7693..a6abc7d39 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -191,7 +191,7 @@ public async Task CreateChangelog_WithPrOption_FetchesPrInfoAndDerivesTitle() var input = new ChangelogInput { - Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], Config = configPath, Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) @@ -392,7 +392,7 @@ public async Task CreateChangelog_WithPrNumberAndOwnerRepo_FetchesPrInfo() var input = new ChangelogInput { - Pr = "12345", + Prs = ["12345"], Owner = "elastic", Repo = "elasticsearch", Title = "Update documentation", @@ -439,7 +439,7 @@ public async Task CreateChangelog_WithExplicitTitle_OverridesPrTitle() var input = new ChangelogInput { - Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Title = "Custom Title Override", Type = "feature", Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], @@ -822,5 +822,297 @@ public async Task CreateChangelog_WithFeatureId_CreatesValidYaml() var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); yamlContent.Should().Contain("feature-id: feature:new-search-api"); } + + [Fact] + public async Task CreateChangelog_WithMultiplePrs_CreatesOneFilePerPr() + { + // Arrange + var mockGitHubService = A.Fake(); + var pr1Info = new GitHubPrInfo + { + Title = "First PR feature", + Labels = ["type:feature"] + }; + var pr2Info = new GitHubPrInfo + { + Title = "Second PR bug fix", + Labels = ["type:bug"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "1234", + null, + null, + A._)) + .Returns(pr1Info); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "5678", + null, + null, + A._)) + .Returns(pr2Info); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + "type:bug": bug-fix + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/1234", "https://github.com/elastic/elasticsearch/pull/5678"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(2); + + var yamlContent1 = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + var yamlContent2 = await File.ReadAllTextAsync(files[1], TestContext.Current.CancellationToken); + + // One file should contain first PR title, the other should contain second PR title + var contents = new[] { yamlContent1, yamlContent2 }; + contents.Should().Contain(c => c.Contains("title: First PR feature")); + contents.Should().Contain(c => c.Contains("title: Second PR bug fix")); + contents.Should().Contain(c => c.Contains("pr: https://github.com/elastic/elasticsearch/pull/1234")); + contents.Should().Contain(c => c.Contains("pr: https://github.com/elastic/elasticsearch/pull/5678")); + } + + [Fact] + public async Task CreateChangelog_WithBlockingLabel_SkipsChangelogCreation() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "PR with blocking label", + Labels = ["type:feature", "skip:releaseNotes"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + product_label_blockers: + elasticsearch: + - "skip:releaseNotes" + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/1234"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); // Should succeed but skip creating changelog + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains("skip:releaseNotes")); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(0); // No files should be created + } + + [Fact] + public async Task CreateChangelog_WithBlockingLabelForSpecificProduct_OnlyBlocksForThatProduct() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "PR with blocking label", + Labels = ["type:feature", "ILM"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + product_label_blockers: + cloud-serverless: + - "ILM" + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/1234"], + Products = [ + new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }, + new ProductInfo { Product = "cloud-serverless", Target = "2025-08-05" } + ], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); // Should succeed but skip creating changelog due to cloud-serverless blocker + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains("ILM") && d.Message.Contains("cloud-serverless")); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(0); // No files should be created + } + + [Fact] + public async Task CreateChangelog_WithMultiplePrsAndSomeBlocked_CreatesFilesForNonBlockedPrs() + { + // Arrange + var mockGitHubService = A.Fake(); + var pr1Info = new GitHubPrInfo + { + Title = "First PR without blocker", + Labels = ["type:feature"] + }; + var pr2Info = new GitHubPrInfo + { + Title = "Second PR with blocker", + Labels = ["type:feature", "skip:releaseNotes"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "1234", + null, + null, + A._)) + .Returns(pr1Info); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + "5678", + null, + null, + A._)) + .Returns(pr2Info); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + product_label_blockers: + elasticsearch: + - "skip:releaseNotes" + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/1234", "https://github.com/elastic/elasticsearch/pull/5678"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains("5678")); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); // Only one file should be created (for PR 1234) + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: First PR without blocker"); + yamlContent.Should().Contain("pr: https://github.com/elastic/elasticsearch/pull/1234"); + yamlContent.Should().NotContain("Second PR with blocker"); + } } From 7d8e21484d98a1431775556cc21906179088e334 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 12 Dec 2025 09:40:09 -0800 Subject: [PATCH 02/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> --- .../ChangelogService.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 940a64ea3..88d5811ee 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -167,13 +167,12 @@ string prUrl var normalizedProductId = product.Product.Replace('_', '-'); if (config.ProductLabelBlockers.TryGetValue(normalizedProductId, out var blockerLabels)) { - foreach (var blockerLabel in blockerLabels) + var matchingBlockerLabel = blockerLabels + .FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)); + if (matchingBlockerLabel != null) { - if (prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)) - { - collector.EmitWarning(string.Empty, $"Skipping changelog creation for PR {prUrl} due to blocking label '{blockerLabel}' for product '{product.Product}'. This label is configured to prevent changelog creation for this product."); - return true; - } + collector.EmitWarning(string.Empty, $"Skipping changelog creation for PR {prUrl} due to blocking label '{matchingBlockerLabel}' for product '{product.Product}'. This label is configured to prevent changelog creation for this product."); + return true; } } } From a39028b9eda32d9fe5ff43769b62a746951029b3 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 09:41:36 -0800 Subject: [PATCH 03/21] Update CLI docs --- docs/_docset.yml | 2 +- docs/_redirects.yml | 6 +++++- docs/cli/{release => changelog}/changelog-add.md | 5 +++-- docs/cli/{release => changelog}/index.md | 6 ++---- docs/cli/index.md | 6 +++--- 5 files changed, 14 insertions(+), 11 deletions(-) rename docs/cli/{release => changelog}/changelog-add.md (95%) rename docs/cli/{release => changelog}/index.md (65%) diff --git a/docs/_docset.yml b/docs/_docset.yml index 80230bceb..e2e5742b1 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -154,7 +154,7 @@ toc: - file: inbound-links-validate.md - file: inbound-links-validate-all.md - file: inbound-links-validate-link-reference.md - - folder: release + - folder: changelog children: - file: index.md - file: changelog-add.md diff --git a/docs/_redirects.yml b/docs/_redirects.yml index 424ba69a5..e390618b5 100644 --- a/docs/_redirects.yml +++ b/docs/_redirects.yml @@ -23,4 +23,8 @@ redirects: "yy": "bb" 'testing/redirects/third-page.md': anchors: - 'removed-anchor': \ No newline at end of file + 'removed-anchor': + 'cli/release/index.md': + to: 'cli/changelog/index.md' + 'cli/release/changelog-add.md': + to: 'cli/changelog/changelog-add.md' \ No newline at end of file diff --git a/docs/cli/release/changelog-add.md b/docs/cli/changelog/changelog-add.md similarity index 95% rename from docs/cli/release/changelog-add.md rename to docs/cli/changelog/changelog-add.md index d17b613f3..a6332af2f 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/changelog/changelog-add.md @@ -50,10 +50,11 @@ docs-builder changelog add [options...] [-h|--help] : The valid product identifiers are listed in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). : The valid lifecycles are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). -`--pr ` -: Optional: Pull request URL or number (if `--owner` and `--repo` are provided). +`--prs ` +: Optional: Pull request URLs or numbers (if `--owner` and `--repo` are provided). : If specified, `--title` can be derived from the PR. : If mappings are configured, `--areas` and `--type` can also be derived from the PR. +: Creates one changelog file per PR. `--repo ` : Optional: GitHub repository name (used when `--pr` is just a number). diff --git a/docs/cli/release/index.md b/docs/cli/changelog/index.md similarity index 65% rename from docs/cli/release/index.md rename to docs/cli/changelog/index.md index 37c29bb46..5e3ee5477 100644 --- a/docs/cli/release/index.md +++ b/docs/cli/changelog/index.md @@ -1,11 +1,9 @@ --- -navigation_title: "release" +navigation_title: "changelog" --- -# Release doc commands +# Changelog commands These commands are associated with product release documentation. -## Changelog commands - - [changelog add](changelog-add.md) - Create a changelog file diff --git a/docs/cli/index.md b/docs/cli/index.md index defe1d465..db29468b5 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -10,7 +10,7 @@ These commands can be roughly grouped into four main categories - [Documentation Set commands](#documentation-set-commands) - [Link commands](#link-commands) - [Assembler commands](#assembler-commands) -- [Release doc commands](#release-doc-commands) +- [Changelog commands](#release-doc-commands) ### Global options @@ -47,8 +47,8 @@ Assembler builds bring together all isolated documentation set builds and turn t [See available CLI commands for assembler](assembler/index.md) -## Release doc commands +## Changelog commands Commands that pertain to creating and publishing product release documentation. -[See available CLI commands for release docs](release/index.md) +[See available CLI commands for release docs](changelog/index.md) From 44458f98616dd60adba0827afb627d0bb4fc6d16 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 12 Dec 2025 09:58:31 -0800 Subject: [PATCH 04/21] Potential fix for pull request finding 'Missed opportunity to use Select' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- .../Elastic.Documentation.Services/ChangelogService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 88d5811ee..e22d1e621 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -89,9 +89,8 @@ Cancel ctx var successCount = 0; var skippedCount = 0; - foreach (var pr in input.Prs) + foreach (var prTrimmed in input.Prs.Select(pr => pr.Trim())) { - var prTrimmed = pr.Trim(); if (string.IsNullOrWhiteSpace(prTrimmed)) continue; From a77aa0015cda3befb18b544941c6cf1ccfdd6777 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Fri, 12 Dec 2025 10:07:21 -0800 Subject: [PATCH 05/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 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index e22d1e621..841326e81 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -89,10 +89,8 @@ Cancel ctx var successCount = 0; var skippedCount = 0; - foreach (var prTrimmed in input.Prs.Select(pr => pr.Trim())) + foreach (var prTrimmed in input.Prs.Select(pr => pr.Trim()).Where(prTrimmed => !string.IsNullOrWhiteSpace(prTrimmed))) { - if (string.IsNullOrWhiteSpace(prTrimmed)) - continue; // Fetch PR information var prInfo = await TryFetchPrInfoAsync(prTrimmed, input.Owner, input.Repo, ctx); From 1bb1f9abeaba5c0a76a150ca6b8053e8a172d675 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 10:28:05 -0800 Subject: [PATCH 06/21] Add docs and tests --- docs/contribute/changelog.md | 94 ++++++++-------- .../ChangelogService.cs | 15 +++ .../ChangelogServiceTests.cs | 102 +++++++++++++++++- 3 files changed, 163 insertions(+), 48 deletions(-) diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 41a880ff6..1682617a3 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -60,7 +60,7 @@ Examples: ## Changelog configuration -Some of the fields in the changelog accept only a specific set of values. +Some of the fields in the changelog accept only a specific set of values: :::{important} @@ -69,6 +69,7 @@ Some of the fields in the changelog accept only a specific set of values. ::: If you want to further limit the list of values, you can optionally create a configuration file. +You can also use the configuration file to prevent the creation of changelogs when certain PR labels are present. Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). By default, the command checks the following path: `docs/changelog.yml`. @@ -84,33 +85,18 @@ If a configuration file exists, the command validates all its values before gene You can optionally add `label_to_type` and `label_to_areas` mappings in your changelog configuration. When you run the command with the `--prs` option, it can use these mappings to fill in the `type` and `areas` in your changelog based on your pull request labels. -Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). - -## Examples +Refer to the file layout in [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example) and an [example usage](#example-map-label). -### Filenames +### GitHub label blockers -By default, the command generates filenames using a timestamp and a sanitized version of the title: -`{timestamp}-{sanitized-title}.yaml` - -For example: `1735689600-fixes-enrich-and-lookup-join-resolution.yaml` +You can also optionally add `product_label_blockers` in your changelog configuration. +When you run the command with the `--prs` and `--products` options and the PR has a label that you've identified as a blocker for that product, the `docs-builder changelog add` command does not create a changelog for that PR. -If you want to use the PR number as the filename instead, use the `--use-pr-number` option: +Refer to the file layout in [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example) and an [example usage](#example-block-label). -```sh -docs-builder changelog add \ - --pr https://github.com/elastic/elasticsearch/pull/137431 \ - --products "elasticsearch 9.2.3" \ - --use-pr-number -``` - -This creates a file named `137431.yaml` instead of the default timestamp-based filename. - -:::{important} -When using `--use-pr-number`, you must also provide the `--pr` option. The PR number is extracted from the PR URL or number you provide. -::: +## Examples -### Multiple products +### Create a changelog for multiple products [example-multiple-products] The following command creates a changelog for a bug fix that applies to two products: @@ -143,7 +129,7 @@ areas: - ES|QL ``` -### PR label mappings +### Create a changelog with PR label mappings [example-map-label] You can update your changelog configuration file to contain GitHub label mappings, for example: @@ -171,18 +157,6 @@ label_to_type: label_to_areas: # Example mappings - customize based on your label naming conventions ":Search Relevance/ES|QL": "ES|QL" - -# Product-specific label blockers (optional) -# Maps product IDs to lists of labels that prevent changelog creation for that product -# If a PR has any of these labels for the specified product, changelog creation will be skipped -product_label_blockers: - # Example: Skip changelog creation for cloud.serverless when PR has "ILM" label - cloud-serverless: - - "ILM" - - "skip:releaseNotes" - # Example: Skip changelog creation for elasticsearch when PR has "skip:releaseNotes" label - elasticsearch: - - "skip:releaseNotes" ``` When you use the `--prs` option to derive information from a pull request, it can make use of those mappings: @@ -204,25 +178,57 @@ areas: title: '[ES|QL] Take TOP_SNIPPETS out of snapshot' ``` -### Product-specific label blockers +### Block changelog creation with PR labels [example-block-label] You can configure product-specific label blockers to prevent changelog creation for certain PRs based on their labels. -When using `--prs` with multiple PRs, if a PR has a blocking label for any of the specified products, that PR will be skipped and no changelog file will be created for it. A warning message will be emitted indicating which PR was skipped and why. +If you run the `docs-builder changelog add` command with the `--prs` option and a PR has a blocking label for any of the products in the `--products` option, that PR will be skipped and no changelog file will be created for it. +A warning message will be emitted indicating which PR was skipped and why. -For example, if you configure: +For example, your configuration file can contain `product_label_blockers` like this: ```yaml +# Product-specific label blockers (optional) +# Maps product IDs to lists of labels that prevent changelog creation for that product +# If you run the changelog add command with the --prs option and a PR has any of these labels, the changelog is not created product_label_blockers: + # Example: Skip changelog for cloud.serverless product when PR has "Watcher" label cloud-serverless: - - "ILM" - - "skip:releaseNotes" + - ":Data Management/Watcher" + - ">non-issue" + # Example: Skip changelog creation for elasticsearch product when PR has "skip:releaseNotes" label + elasticsearch: + - ">non-issue" ``` -And run: +Those settings affect commands with the `--prs` option, for example: ```sh -docs-builder changelog add --prs "1234, 5678" --products "cloud-serverless 2025-08-05" --owner elastic --repo elasticsearch +docs-builder changelog add --prs "1234, 5678" --products "cloud-serverless" --owner elastic --repo elasticsearch --config test/changelog.yml ``` -If PR 1234 has the "ILM" label, it will be skipped and no changelog will be created for it. If PR 5678 does not have any blocking labels, a changelog will be created for it. +If PR 1234 has the `>non-issue` or Watcher label, it will be skipped and no changelog will be created for it. +If PR 5678 does not have any blocking labels, a changelog is created. + + +### Filenames + +By default, the `docs-builder changelog add` command generates filenames using a timestamp and a sanitized version of the title: +`{timestamp}-{sanitized-title}.yaml` + +For example: `1735689600-fixes-enrich-and-lookup-join-resolution.yaml` + +If you want to use the PR number as the filename instead, add the `--use-pr-number` option: + +```sh +docs-builder changelog add \ + --pr https://github.com/elastic/elasticsearch/pull/137431 \ + --products "elasticsearch 9.2.3" \ + --use-pr-number +``` + +This creates a file named `137431.yaml` instead of the default timestamp-based filename. + +:::{important} +When using `--use-pr-number`, you must also provide the `--pr` option. The PR number is extracted from the PR URL or number you provide. +::: diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 841326e81..7f6aa509f 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -417,6 +417,21 @@ Cancel ctx } } + // Validate product_label_blockers (if specified) - product keys must be from products.yml + if (config.ProductLabelBlockers != null && config.ProductLabelBlockers.Count > 0) + { + foreach (var productKey in config.ProductLabelBlockers.Keys) + { + var normalizedProductId = productKey.Replace('_', '-'); + if (!validProductIds.Contains(normalizedProductId)) + { + var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); + collector.EmitError(finalConfigPath, $"Product '{productKey}' in product_label_blockers in changelog.yml is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + return null; + } + } + } + return config; } catch (IOException ex) diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index a6abc7d39..38f74fb0d 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -266,7 +266,7 @@ public async Task CreateChangelog_WithPrOptionAndLabelMapping_MapsLabelsToType() var input = new ChangelogInput { - Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], Config = configPath, Output = fs.Path.Combine(fs.Path.GetTempPath(), Guid.NewGuid().ToString()) @@ -338,7 +338,7 @@ public async Task CreateChangelog_WithPrOptionAndAreaMapping_MapsLabelsToAreas() var input = new ChangelogInput { - Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], Config = configPath, Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) @@ -646,7 +646,7 @@ public async Task CreateChangelog_WithPrOptionButNoLabelMapping_ReturnsError() var input = new ChangelogInput { - Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], Config = configPath, Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) @@ -679,7 +679,7 @@ public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError() var input = new ChangelogInput { - Pr = "https://github.com/elastic/elasticsearch/pull/12345", + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) }; @@ -743,6 +743,100 @@ public async Task CreateChangelog_WithInvalidType_ReturnsError() _collector.Diagnostics.Should().Contain(d => d.Message.Contains("is not in the list of available types")); } + [Fact] + public async Task CreateChangelog_WithInvalidProductInProductLabelBlockers_ReturnsError() + { + // Arrange + var mockGitHubService = A.Fake(); + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + product_label_blockers: + invalid-product: + - "skip:releaseNotes" + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Title = "Test", + Type = "feature", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeFalse(); + _collector.Errors.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Product 'invalid-product' in product_label_blockers") && d.Message.Contains("is not in the list of available products")); + } + + [Fact] + public async Task CreateChangelog_WithValidProductInProductLabelBlockers_Succeeds() + { + // Arrange + var mockGitHubService = A.Fake(); + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + product_label_blockers: + elasticsearch: + - "skip:releaseNotes" + cloud-hosted: + - "ILM" + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Title = "Test", + Type = "feature", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + if (!result) + { + foreach (var diagnostic in _collector.Diagnostics) + { + _output.WriteLine($"{diagnostic.Severity}: {diagnostic.Message}"); + } + } + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + } + [Fact] public async Task CreateChangelog_WithHighlightFlag_CreatesValidYaml() { From 2c6ddc5036148bba2fcf015c37acb542818ab75c Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 10:35:17 -0800 Subject: [PATCH 07/21] Fix link --- docs/cli/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index db29468b5..55f5242de 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -10,7 +10,7 @@ These commands can be roughly grouped into four main categories - [Documentation Set commands](#documentation-set-commands) - [Link commands](#link-commands) - [Assembler commands](#assembler-commands) -- [Changelog commands](#release-doc-commands) +- [Changelog commands](#changelog-commands) ### Global options From 473fa81aba65da1db96fcca6bd07e39c70914ae5 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 17:59:45 -0800 Subject: [PATCH 08/21] Re-add cli files --- docs/cli/release/changelog-add.md | 68 +++++++++++++++++++++++++++++++ docs/cli/release/index.md | 11 +++++ 2 files changed, 79 insertions(+) create mode 100644 docs/cli/release/changelog-add.md create mode 100644 docs/cli/release/index.md diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md new file mode 100644 index 000000000..45d3e31ab --- /dev/null +++ b/docs/cli/release/changelog-add.md @@ -0,0 +1,68 @@ +# changelog add + +Create a changelog file that describes a single item in the release documentation. +For details and examples, go to [](/contribute/changelog.md). + +## Usage + +```sh +docs-builder changelog add [options...] [-h|--help] +``` + +## Options + +`--action ` +: Optional: What users must do to mitigate. + +`--areas ` +: Optional: Areas affected (comma-separated or specify multiple times). + +`--config ` +: Optional: Path to the changelog.yml configuration file. Defaults to `docs/changelog.yml`. + +`--description ` +: Optional: Additional information about the change (max 600 characters). + +`--feature-id ` +: Optional: Feature flag ID + +`--highlight ` +: Optional: Include in release highlights. + +`--impact ` +: Optional: How the user's environment is affected. + +`--issues ` +: Optional: Issue numbers (comma-separated or specify multiple times). + +`--output ` +: Optional: Output directory for the changelog fragment. Defaults to current directory. + +`--owner ` +: Optional: GitHub repository owner (used when `--pr` is just a number). + +`--products >` +: Required: Products affected in format "product target lifecycle, ..." (for example, `"elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05"`). +: The valid product identifiers are listed in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). +: The valid lifecycles are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). + +`--pr ` +: Optional: Pull request URL or number (if `--owner` and `--repo` are provided). +: If specified, `--title` can be derived from the PR. +: If mappings are configured, `--areas` and `--type` can also be derived from the PR. + +`--repo ` +: Optional: GitHub repository name (used when `--pr` is just a number). + +`--subtype ` +: Optional: Subtype for breaking changes (for example, `api`, `behavioral`, or `configuration`). +: The valid subtypes are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). + +`--title ` +: A short, user-facing title (max 80 characters) +: Required if `--pr` is not specified. +: If both `--pr` and `--title` are specified, the latter value is used instead of what exists in the PR. + +`--type ` +: Required: Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). +: The valid types are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). diff --git a/docs/cli/release/index.md b/docs/cli/release/index.md new file mode 100644 index 000000000..37c29bb46 --- /dev/null +++ b/docs/cli/release/index.md @@ -0,0 +1,11 @@ +--- +navigation_title: "release" +--- + +# Release doc commands + +These commands are associated with product release documentation. + +## Changelog commands + +- [changelog add](changelog-add.md) - Create a changelog file From ae74f3c9ea54203887cad0168baf63500fc65ef4 Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 18:01:23 -0800 Subject: [PATCH 09/21] Remove cli release docs --- docs/cli/release/changelog-add.md | 68 ------------------------------- docs/cli/release/index.md | 11 ----- 2 files changed, 79 deletions(-) delete mode 100644 docs/cli/release/changelog-add.md delete mode 100644 docs/cli/release/index.md diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md deleted file mode 100644 index 45d3e31ab..000000000 --- a/docs/cli/release/changelog-add.md +++ /dev/null @@ -1,68 +0,0 @@ -# changelog add - -Create a changelog file that describes a single item in the release documentation. -For details and examples, go to [](/contribute/changelog.md). - -## Usage - -```sh -docs-builder changelog add [options...] [-h|--help] -``` - -## Options - -`--action ` -: Optional: What users must do to mitigate. - -`--areas ` -: Optional: Areas affected (comma-separated or specify multiple times). - -`--config ` -: Optional: Path to the changelog.yml configuration file. Defaults to `docs/changelog.yml`. - -`--description ` -: Optional: Additional information about the change (max 600 characters). - -`--feature-id ` -: Optional: Feature flag ID - -`--highlight ` -: Optional: Include in release highlights. - -`--impact ` -: Optional: How the user's environment is affected. - -`--issues ` -: Optional: Issue numbers (comma-separated or specify multiple times). - -`--output ` -: Optional: Output directory for the changelog fragment. Defaults to current directory. - -`--owner ` -: Optional: GitHub repository owner (used when `--pr` is just a number). - -`--products >` -: Required: Products affected in format "product target lifecycle, ..." (for example, `"elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05"`). -: The valid product identifiers are listed in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). -: The valid lifecycles are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). - -`--pr ` -: Optional: Pull request URL or number (if `--owner` and `--repo` are provided). -: If specified, `--title` can be derived from the PR. -: If mappings are configured, `--areas` and `--type` can also be derived from the PR. - -`--repo ` -: Optional: GitHub repository name (used when `--pr` is just a number). - -`--subtype ` -: Optional: Subtype for breaking changes (for example, `api`, `behavioral`, or `configuration`). -: The valid subtypes are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). - -`--title ` -: A short, user-facing title (max 80 characters) -: Required if `--pr` is not specified. -: If both `--pr` and `--title` are specified, the latter value is used instead of what exists in the PR. - -`--type ` -: Required: Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). -: The valid types are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). diff --git a/docs/cli/release/index.md b/docs/cli/release/index.md deleted file mode 100644 index 37c29bb46..000000000 --- a/docs/cli/release/index.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -navigation_title: "release" ---- - -# Release doc commands - -These commands are associated with product release documentation. - -## Changelog commands - -- [changelog add](changelog-add.md) - Create a changelog file From d7ba7a672cf02f063a2750071a5431bcfa9c741c Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 18:58:13 -0800 Subject: [PATCH 10/21] Improve docs --- docs/contribute/changelog.md | 119 ++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 52 deletions(-) diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 1682617a3..f790a8a7e 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -1,28 +1,73 @@ -# Add changelog entries +# Create changelogs -The `docs-builder changelog add` command creates a new changelog file from command-line input. -By adding a file for each notable change, you can ultimately generate release documention with a consistent layout for all your products. +By adding a changelog file for each notable change, you can ultimately generate release documention with a consistent layout for all your products. + +These instructions rely on the use of a common changelog schema: + +:::{dropdown} Changelog schema +::::{include} /contribute/_snippets/changelog-fields.md +:::: +::: + +Some of the fields in the schema accept only a specific set of values: + +:::{important} + +- Product values must exist in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). Invalid products will cause the `docs-builder changelog add` command to fail. +- Type, subtype, and lifecycle values must match the available values defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). Invalid values will cause the `docs-builder changelog add` command to fail. +::: + +To use the `docs-builder changelog` commands in your development workflow: + +1. Ensure that your products exist in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). +1. Add labels to your GitHub pull requests to represent the types defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). For example, `>bug` and `>enhancement` labels. +1. Optional: Choose areas or components that your changes affect and add labels to your GitHub pull requests (such as `:Analytics/Aggregations`). +1. Optional: Add labels to your GitHub pull requests to indicate that they are not notable and should not generate changelogs. For example, `non-issue` or `release_notes:skip`. +1. [Configure changelog settings](#changelog-settings) to correctly interpret your PR labels. +1. [Create changelogs](#changelog-add) with the `docs-builder changelog add` command. + +For more information about running `docs-builder`, go to [Contribute locally](https://www.elastic.co/docs/contribute-docs/locally). :::{note} This command is associated with an ongoing release docs initiative. Additional workflows are still to come for managing the list of changelogs in each release. ::: -The command generates a YAML file that uses the following schema: +## Create a changelog configuration file [changelog-settings] -:::{dropdown} Changelog schema -::::{include} /contribute/_snippets/changelog-fields.md -:::: -::: +You can create a configuration file to limit the acceptable product, type, subtype, and lifecycle values. +You can also use it to prevent the creation of changelogs when certain PR labels are present. +Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). + +By default, the `docs-builder changelog add` command checks the following path: `docs/changelog.yml`. +You can specify a different path with the `--config` command option. -## Command options +If a configuration file exists, the command validates its values before generating changelog files: -The command supports all of the following options, which generally align with fields in the changelog schema: +- If the configuration file contains `lifecycle`, `product`, `subtype`, or `type` values that don't match the values in `products.yml` and `ChangelogConfiguration.cs`, validation fails. The changelog file is not created. +- If the configuration file contains `areas` values and they don't match what you specify in the `--areas` command option, validation fails. The changelog file is not created. -```sh -Usage: changelog add [options...] [-h|--help] [--version] +### GitHub label mappings + +You can optionally add `label_to_type` and `label_to_areas` mappings in your changelog configuration. +When you run the `docs-builder changelog add` command with the `--prs` option, it can use these mappings to fill in the `type` and `areas` in your changelog based on your pull request labels. + +Refer to the file layout in [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example) and an [example usage](#example-map-label). + +### GitHub label blockers -Add a new changelog fragment from command-line input +You can also optionally use `add_blockers` in your changelog configuration. +When you run the `docs-builder changelog add` command with the `--prs` and `--products` options and the PR has a label that you've identified as a blocker for that product, the command does not create a changelog for that PR. + +Refer to the file layout in [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example) and an [example usage](#example-block-label). + +## Create changelog files [changelog-add] + +You can use the `docs-builder changelog add` command to create a changelog file. +For up-to-date details, use the `-h` option: + +```sh +Add a new changelog from command-line input Options: --products > Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05") [Required] @@ -58,42 +103,6 @@ Examples: - `"cloud-serverless 2025-08-05"` - `"cloud-enterprise 4.0.3, cloud-hosted 2025-10-31"` -## Changelog configuration - -Some of the fields in the changelog accept only a specific set of values: - -:::{important} - -- Product values must exist in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). Invalid products will cause the command to fail. -- Type, subtype, and lifecycle values must match the available values defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). Invalid values will cause the command to fail. -::: - -If you want to further limit the list of values, you can optionally create a configuration file. -You can also use the configuration file to prevent the creation of changelogs when certain PR labels are present. -Refer to [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example). - -By default, the command checks the following path: `docs/changelog.yml`. -You can specify a different path with the `--config` command option. - -If a configuration file exists, the command validates all its values before generating the changelog file: - -- If the configuration file contains `lifecycle`, `product`, `subtype`, or `type` values that don't match the values in `products.yml` and `ChangelogConfiguration.cs`, validation fails. The changelog file is not created. -- If the configuration file contains `areas` values and they don't match what you specify in the `--areas` command option, validation fails. The changelog file is not created. - -### GitHub label mappings - -You can optionally add `label_to_type` and `label_to_areas` mappings in your changelog configuration. -When you run the command with the `--prs` option, it can use these mappings to fill in the `type` and `areas` in your changelog based on your pull request labels. - -Refer to the file layout in [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example) and an [example usage](#example-map-label). - -### GitHub label blockers - -You can also optionally add `product_label_blockers` in your changelog configuration. -When you run the command with the `--prs` and `--products` options and the PR has a label that you've identified as a blocker for that product, the `docs-builder changelog add` command does not create a changelog for that PR. - -Refer to the file layout in [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example) and an [example usage](#example-block-label). - ## Examples ### Create a changelog for multiple products [example-multiple-products] @@ -162,10 +171,13 @@ label_to_areas: When you use the `--prs` option to derive information from a pull request, it can make use of those mappings: ```sh -docs-builder changelog add --prs https://github.com/elastic/elasticsearch/pull/139272 --products "elasticsearch 9.3.0" --config test/changelog.yml +docs-builder changelog add \ + --prs https://github.com/elastic/elasticsearch/pull/139272 \ + --products "elasticsearch 9.3.0" \ + --config test/changelog.yml ``` -In this case, the changelog file derives the title, type, and areas: +In this case, the changelog file derives the title, type, and areas from the pull request: ```yaml pr: https://github.com/elastic/elasticsearch/pull/139272 @@ -204,7 +216,10 @@ product_label_blockers: Those settings affect commands with the `--prs` option, for example: ```sh -docs-builder changelog add --prs "1234, 5678" --products "cloud-serverless" --owner elastic --repo elasticsearch --config test/changelog.yml +docs-builder changelog add --prs "1234, 5678" \ + --products "cloud-serverless" \ + --owner elastic --repo elasticsearch \ + --config test/changelog.yml ``` If PR 1234 has the `>non-issue` or Watcher label, it will be skipped and no changelog will be created for it. From 38aaa93f8790e7adbf0bd04a5e55eb2cc3874d6c Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 19:12:33 -0800 Subject: [PATCH 11/21] Revert docs filename change --- docs/_docset.yml | 2 +- docs/_redirects.yml | 6 +----- docs/cli/{changelog => release}/changelog-add.md | 0 docs/cli/{changelog => release}/index.md | 0 4 files changed, 2 insertions(+), 6 deletions(-) rename docs/cli/{changelog => release}/changelog-add.md (100%) rename docs/cli/{changelog => release}/index.md (100%) diff --git a/docs/_docset.yml b/docs/_docset.yml index e2e5742b1..80230bceb 100644 --- a/docs/_docset.yml +++ b/docs/_docset.yml @@ -154,7 +154,7 @@ toc: - file: inbound-links-validate.md - file: inbound-links-validate-all.md - file: inbound-links-validate-link-reference.md - - folder: changelog + - folder: release children: - file: index.md - file: changelog-add.md diff --git a/docs/_redirects.yml b/docs/_redirects.yml index e390618b5..424ba69a5 100644 --- a/docs/_redirects.yml +++ b/docs/_redirects.yml @@ -23,8 +23,4 @@ redirects: "yy": "bb" 'testing/redirects/third-page.md': anchors: - 'removed-anchor': - 'cli/release/index.md': - to: 'cli/changelog/index.md' - 'cli/release/changelog-add.md': - to: 'cli/changelog/changelog-add.md' \ No newline at end of file + 'removed-anchor': \ No newline at end of file diff --git a/docs/cli/changelog/changelog-add.md b/docs/cli/release/changelog-add.md similarity index 100% rename from docs/cli/changelog/changelog-add.md rename to docs/cli/release/changelog-add.md diff --git a/docs/cli/changelog/index.md b/docs/cli/release/index.md similarity index 100% rename from docs/cli/changelog/index.md rename to docs/cli/release/index.md From 4222160e36b808c848a6d739244bdb91776e692e Mon Sep 17 00:00:00 2001 From: lcawl Date: Fri, 12 Dec 2025 19:19:48 -0800 Subject: [PATCH 12/21] Fix link --- docs/cli/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index 55f5242de..044b19b21 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -51,4 +51,4 @@ Assembler builds bring together all isolated documentation set builds and turn t Commands that pertain to creating and publishing product release documentation. -[See available CLI commands for release docs](changelog/index.md) +[See available CLI commands for release docs](release/index.md) From 5e2239fa239c85c799be84f92468ea1f8cbb71e2 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 5 Jan 2026 07:37:37 -0800 Subject: [PATCH 13/21] Update docsbuilder changelog add to handle PR fetch failures (#2407) Co-authored-by: Felipe Cotti --- docs-builder.slnx | 1 + .../ChangelogService.cs | 205 ++++++++++++------ .../ChangelogServiceTests.cs | 145 ++++++++++++- 3 files changed, 277 insertions(+), 74 deletions(-) diff --git a/docs-builder.slnx b/docs-builder.slnx index 62b285671..f2c52259d 100644 --- a/docs-builder.slnx +++ b/docs-builder.slnx @@ -90,6 +90,7 @@ + diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 7f6aa509f..ca74fc656 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -40,8 +40,8 @@ Cancel ctx return false; } - // Handle multiple PRs if provided - if (input.Prs != null && input.Prs.Length > 0) + // Handle multiple PRs if provided (more than one PR) + if (input.Prs != null && input.Prs.Length > 1) { return await CreateChangelogsForMultiplePrs(collector, input, config, ctx); } @@ -96,16 +96,18 @@ Cancel ctx var prInfo = await TryFetchPrInfoAsync(prTrimmed, input.Owner, input.Repo, ctx); if (prInfo == null) { - collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prTrimmed}. Skipping this PR."); - continue; + // PR fetch failed - continue anyway to generate basic changelog + collector.EmitWarning(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prTrimmed}. Generating basic changelog with provided values."); } - - // Check for label blockers - var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prTrimmed); - if (shouldSkip) + else { - skippedCount++; - continue; + // Check for label blockers (only if we successfully fetched PR info) + var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prTrimmed); + if (shouldSkip) + { + skippedCount++; + continue; + } } // Create a copy of input for this PR @@ -186,6 +188,7 @@ Cancel ctx { // Get the PR URL if Prs is provided (for single PR processing) var prUrl = input.Prs != null && input.Prs.Length > 0 ? input.Prs[0] : null; + var prFetchFailed = false; // Validate that if PR is just a number, owner and repo must be provided if (!string.IsNullOrWhiteSpace(prUrl) @@ -202,77 +205,103 @@ Cancel ctx var prInfo = await TryFetchPrInfoAsync(prUrl, input.Owner, input.Repo, ctx); if (prInfo == null) { - collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Cannot derive title and type."); - return false; + // PR fetch failed - continue anyway if --prs was provided + prFetchFailed = true; + collector.EmitWarning(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Generating basic changelog with provided values."); } - - // Use PR title if title was not explicitly provided - if (string.IsNullOrWhiteSpace(input.Title)) + else { - if (string.IsNullOrWhiteSpace(prInfo.Title)) + // Check for label blockers (only if we successfully fetched PR info) + var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prUrl); + if (shouldSkip) { - collector.EmitError(string.Empty, $"PR {prUrl} does not have a title. Please provide --title or ensure the PR has a title."); - return false; + // Return true but don't create changelog (similar to multiple PRs behavior) + return true; } - input.Title = prInfo.Title; - _logger.LogInformation("Using PR title: {Title}", input.Title); - } - else - { - _logger.LogDebug("Using explicitly provided title, ignoring PR title"); - } - // Map labels to type if type was not explicitly provided - if (string.IsNullOrWhiteSpace(input.Type)) - { - if (config.LabelToType == null || config.LabelToType.Count == 0) + // Use PR title if title was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Title)) + { + if (string.IsNullOrWhiteSpace(prInfo.Title)) + { + collector.EmitError(string.Empty, $"PR {prUrl} does not have a title. Please provide --title or ensure the PR has a title."); + return false; + } + input.Title = prInfo.Title; + _logger.LogInformation("Using PR title: {Title}", input.Title); + } + else { - collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels: no label-to-type mapping configured in changelog.yml. Please provide --type or configure label_to_type in changelog.yml."); - return false; + _logger.LogDebug("Using explicitly provided title, ignoring PR title"); } - var mappedType = MapLabelsToType(prInfo.Labels, config.LabelToType); - if (mappedType == null) + // Map labels to type if type was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Type)) { - var availableLabels = prInfo.Labels.Length > 0 ? string.Join(", ", prInfo.Labels) : "none"; - collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels ({availableLabels}). No matching label found in label_to_type mapping. Please provide --type or add a label mapping in changelog.yml."); - return false; + if (config.LabelToType == null || config.LabelToType.Count == 0) + { + collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels: no label-to-type mapping configured in changelog.yml. Please provide --type or configure label_to_type in changelog.yml."); + return false; + } + + var mappedType = MapLabelsToType(prInfo.Labels, config.LabelToType); + if (mappedType == null) + { + var availableLabels = prInfo.Labels.Length > 0 ? string.Join(", ", prInfo.Labels) : "none"; + collector.EmitError(string.Empty, $"Cannot derive type from PR {prUrl} labels ({availableLabels}). No matching label found in label_to_type mapping. Please provide --type or add a label mapping in changelog.yml."); + return false; + } + input.Type = mappedType; + _logger.LogInformation("Mapped PR labels to type: {Type}", input.Type); + } + else + { + _logger.LogDebug("Using explicitly provided type, ignoring PR labels"); } - input.Type = mappedType; - _logger.LogInformation("Mapped PR labels to type: {Type}", input.Type); - } - else - { - _logger.LogDebug("Using explicitly provided type, ignoring PR labels"); - } - // Map labels to areas if areas were not explicitly provided - if ((input.Areas == null || input.Areas.Length == 0) && config.LabelToAreas != null) - { - var mappedAreas = MapLabelsToAreas(prInfo.Labels, config.LabelToAreas); - if (mappedAreas.Count > 0) + // Map labels to areas if areas were not explicitly provided + if (input.Areas.Length == 0 && config.LabelToAreas != null) { - input.Areas = mappedAreas.ToArray(); - _logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas)); + var mappedAreas = MapLabelsToAreas(prInfo.Labels, config.LabelToAreas); + if (mappedAreas.Count > 0) + { + input.Areas = mappedAreas.ToArray(); + _logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas)); + } + } + else if (input.Areas != null && input.Areas.Length > 0) + { + _logger.LogDebug("Using explicitly provided areas, ignoring PR labels"); } - } - else if (input.Areas != null && input.Areas.Length > 0) - { - _logger.LogDebug("Using explicitly provided areas, ignoring PR labels"); } } // Validate required fields (must be provided either explicitly or derived from PR) + // If PR fetch failed, allow missing title/type and warn instead of erroring if (string.IsNullOrWhiteSpace(input.Title)) { - collector.EmitError(string.Empty, "Title is required. Provide --title or specify --prs to derive it from the PR."); - return false; + if (prFetchFailed) + { + collector.EmitWarning(string.Empty, "Title is missing. The changelog will be created with title commented out. Please manually update the title field."); + } + else + { + collector.EmitError(string.Empty, "Title is required. Provide --title or specify --prs to derive it from the PR."); + return false; + } } if (string.IsNullOrWhiteSpace(input.Type)) { - collector.EmitError(string.Empty, "Type is required. Provide --type or specify --prs to derive it from PR labels (requires label_to_type mapping in changelog.yml)."); - return false; + if (prFetchFailed) + { + collector.EmitWarning(string.Empty, "Type is missing. The changelog will be created with type commented out. Please manually update the type field."); + } + else + { + collector.EmitError(string.Empty, "Type is required. Provide --type or specify --prs to derive it from PR labels (requires label_to_type mapping in changelog.yml)."); + return false; + } } if (input.Products.Count == 0) @@ -281,8 +310,8 @@ Cancel ctx return false; } - // Validate type is in allowed list - if (!config.AvailableTypes.Contains(input.Type)) + // Validate type is in allowed list (only if type is provided) + if (!string.IsNullOrWhiteSpace(input.Type) && !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; @@ -330,7 +359,7 @@ Cancel ctx var changelogData = BuildChangelogData(input, prUrl); // Generate YAML file - var yamlContent = GenerateYaml(changelogData, config); + var yamlContent = GenerateYaml(changelogData, config, string.IsNullOrWhiteSpace(input.Title), string.IsNullOrWhiteSpace(input.Type)); // Determine output path var outputDir = input.Output ?? Directory.GetCurrentDirectory(); @@ -341,7 +370,9 @@ Cancel ctx // Generate filename (timestamp-slug.yaml) var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var slug = SanitizeFilename(input.Title); + var slug = string.IsNullOrWhiteSpace(input.Title) + ? (prUrl != null ? $"pr-{prUrl.Replace("/", "-").Replace(":", "-")}" : "changelog") + : SanitizeFilename(input.Title); var filename = $"{timestamp}-{slug}.yaml"; var filePath = _fileSystem.Path.Combine(outputDir, filename); @@ -453,11 +484,11 @@ Cancel ctx private static ChangelogData BuildChangelogData(ChangelogInput input, string? prUrl = null) { - // Title and Type are guaranteed to be non-null at this point due to validation above + // Use empty strings if title/type are null (they'll be commented out in YAML generation) var data = new ChangelogData { - Title = input.Title!, - Type = input.Type!, + Title = input.Title ?? string.Empty, + Type = input.Type ?? string.Empty, Subtype = input.Subtype, Description = input.Description, Impact = input.Impact, @@ -481,7 +512,7 @@ private static ChangelogData BuildChangelogData(ChangelogInput input, string? pr return data; } - private string GenerateYaml(ChangelogData data, ChangelogConfiguration config) + private string GenerateYaml(ChangelogData data, ChangelogConfiguration config, bool titleMissing = false, bool typeMissing = false) { // Ensure areas is null if empty to omit it from YAML if (data.Areas != null && data.Areas.Count == 0) @@ -491,6 +522,18 @@ private string GenerateYaml(ChangelogData data, ChangelogConfiguration config) if (data.Issues != null && data.Issues.Count == 0) data.Issues = null; + // Temporarily remove title/type if they're missing so they don't appear in YAML + var originalTitle = data.Title; + var originalType = data.Type; + if (titleMissing) + { + data.Title = string.Empty; + } + if (typeMissing) + { + data.Type = string.Empty; + } + var serializer = new StaticSerializerBuilder(new ChangelogYamlStaticContext()) .WithNamingConvention(UnderscoredNamingConvention.Instance) .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections) @@ -498,6 +541,36 @@ private string GenerateYaml(ChangelogData data, ChangelogConfiguration config) var yaml = serializer.Serialize(data); + // Restore original values + data.Title = originalTitle; + data.Type = originalType; + + // Comment out missing title/type fields - insert at the beginning of the YAML data + if (titleMissing || typeMissing) + { + var lines = yaml.Split('\n').ToList(); + var commentedFields = new List(); + + if (titleMissing) + { + commentedFields.Add("# title: # TODO: Add title"); + } + if (typeMissing) + { + commentedFields.Add("# type: # TODO: Add type (e.g., feature, enhancement, bug-fix, breaking-change)"); + } + + // Find the first non-empty, non-comment line (start of actual YAML data) + var insertIndex = lines.FindIndex(line => + !string.IsNullOrWhiteSpace(line) && + !line.TrimStart().StartsWith('#') && + !line.TrimStart().StartsWith("---", StringComparison.Ordinal)); + + lines.InsertRange(insertIndex >= 0 ? insertIndex : lines.Count, commentedFields); + + yaml = string.Join('\n', lines); + } + // Build types list var typesList = string.Join("\n", config.AvailableTypes.Select(t => $"# - {t}")); diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index 38f74fb0d..223ae233f 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -78,6 +78,14 @@ public ChangelogServiceTests(ITestOutputHelper output) DisplayName = "Elastic Cloud Hosted", VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack) } + }, + { + "cloud-serverless", new Product + { + Id = "cloud-serverless", + DisplayName = "Elastic Cloud Serverless", + VersioningSystem = versionsConfiguration.GetVersioningSystem(VersioningSystemId.Stack) + } } }.ToFrozenDictionary() }; @@ -662,7 +670,7 @@ public async Task CreateChangelog_WithPrOptionButNoLabelMapping_ReturnsError() } [Fact] - public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError() + public async Task CreateChangelog_WithPrOptionButPrFetchFails_WithTitleAndType_CreatesChangelog() { // Arrange var mockGitHubService = A.Fake(); @@ -680,6 +688,8 @@ public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError() var input = new ChangelogInput { Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Title = "Manual title provided", + Type = "feature", Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) }; @@ -688,9 +698,128 @@ public async Task CreateChangelog_WithPrOptionButPrFetchFails_ReturnsError() var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); // Assert - result.Should().BeFalse(); - _collector.Errors.Should().BeGreaterThan(0); - _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Failed to fetch PR information")); + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Failed to fetch PR information") && d.Severity == Severity.Warning); + + // Verify changelog file was created with provided values + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Manual title provided"); + yamlContent.Should().Contain("type: feature"); + yamlContent.Should().Contain("pr: https://github.com/elastic/elasticsearch/pull/12345"); + } + + [Fact] + public async Task CreateChangelog_WithPrOptionButPrFetchFails_WithoutTitleAndType_CreatesChangelogWithCommentedFields() + { + // Arrange + var mockGitHubService = A.Fake(); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns((GitHubPrInfo?)null); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345"], + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Failed to fetch PR information") && d.Severity == Severity.Warning); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Title is missing") && d.Severity == Severity.Warning); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Type is missing") && d.Severity == Severity.Warning); + + // Verify changelog file was created with commented title/type + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(1); + + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("# title: # TODO: Add title"); + yamlContent.Should().Contain("# type: # TODO: Add type"); + yamlContent.Should().Contain("pr: https://github.com/elastic/elasticsearch/pull/12345"); + yamlContent.Should().Contain("products:"); + // Should not contain uncommented title/type + var lines = yamlContent.Split('\n'); + lines.Should().NotContain(l => l.Trim().StartsWith("title:", StringComparison.Ordinal) && !l.Trim().StartsWith('#')); + lines.Should().NotContain(l => l.Trim().StartsWith("type:", StringComparison.Ordinal) && !l.Trim().StartsWith('#')); + } + + [Fact] + public async Task CreateChangelog_WithMultiplePrsButPrFetchFails_GeneratesBasicChangelogs() + { + // Arrange + var mockGitHubService = A.Fake(); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns((GitHubPrInfo?)null); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + var fileSystem = new FileSystem(); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/12345", "https://github.com/elastic/elasticsearch/pull/67890"], + Title = "Shared title", + Type = "bug-fix", + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0" }], + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + _collector.Warnings.Should().BeGreaterThan(0); + // Verify that warnings were emitted for both PRs (may be multiple warnings per PR) + var prWarnings = _collector.Diagnostics.Where(d => d.Message.Contains("Failed to fetch PR information")).ToList(); + prWarnings.Should().HaveCountGreaterThanOrEqualTo(2); + // Verify both PR URLs are mentioned in warnings + prWarnings.Should().Contain(d => d.Message.Contains("12345")); + prWarnings.Should().Contain(d => d.Message.Contains("67890")); + + // Verify changelog file was created (may be 1 file if both PRs have same title/type, which is expected) + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCountGreaterThanOrEqualTo(1); + + // Verify the file contains the provided title/type and at least one PR reference + var yamlContent = await File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yamlContent.Should().Contain("title: Shared title"); + yamlContent.Should().Contain("type: bug-fix"); + // Should reference at least one of the PRs (when filenames collide, the last one wins) + yamlContent.Should().MatchRegex(@"pr:\s*(https://github\.com/elastic/elasticsearch/pull/12345|https://github\.com/elastic/elasticsearch/pull/67890)"); } [Fact] @@ -934,14 +1063,14 @@ public async Task CreateChangelog_WithMultiplePrs_CreatesOneFilePerPr() }; A.CallTo(() => mockGitHubService.FetchPrInfoAsync( - "1234", + A.That.Contains("1234"), null, null, A._)) .Returns(pr1Info); A.CallTo(() => mockGitHubService.FetchPrInfoAsync( - "5678", + A.That.Contains("5678"), null, null, A._)) @@ -1146,14 +1275,14 @@ public async Task CreateChangelog_WithMultiplePrsAndSomeBlocked_CreatesFilesForN }; A.CallTo(() => mockGitHubService.FetchPrInfoAsync( - "1234", + A.That.Contains("1234"), null, null, A._)) .Returns(pr1Info); A.CallTo(() => mockGitHubService.FetchPrInfoAsync( - "5678", + A.That.Contains("5678"), null, null, A._)) From 097a2a1a4989de205772d9334491b821fa85a780 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 5 Jan 2026 09:31:00 -0800 Subject: [PATCH 14/21] Potential fix for pull request finding 'Dereferenced variable may be null' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/services/Elastic.Documentation.Services/ChangelogService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index ca74fc656..3e29ea4d7 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -260,7 +260,7 @@ Cancel ctx } // Map labels to areas if areas were not explicitly provided - if (input.Areas.Length == 0 && config.LabelToAreas != null) + if (input.Areas != null && input.Areas.Length == 0 && config.LabelToAreas != null) { var mappedAreas = MapLabelsToAreas(prInfo.Labels, config.LabelToAreas); if (mappedAreas.Count > 0) From 3ffa99652dc2bccc64e6a743f715fa41ebda35d5 Mon Sep 17 00:00:00 2001 From: lcawl Date: Tue, 6 Jan 2026 10:20:30 -0800 Subject: [PATCH 15/21] Change keyword,add multi-product support --- config/changelog.yml.example | 16 ++-- docs/cli/release/changelog-add.md | 1 + docs/contribute/changelog.md | 10 ++- .../Changelog/ChangelogConfiguration.cs | 3 +- .../ChangelogService.cs | 36 ++++++-- .../ChangelogServiceTests.cs | 82 +++++++++++++++++-- 6 files changed, 124 insertions(+), 24 deletions(-) diff --git a/config/changelog.yml.example b/config/changelog.yml.example index 4a494edf9..9091fa0be 100644 --- a/config/changelog.yml.example +++ b/config/changelog.yml.example @@ -77,14 +77,16 @@ label_to_areas: # "area:multiple": "search, security" # Multiple areas comma-separated # Product-specific label blockers (optional) -# Maps product IDs to lists of labels that prevent changelog creation for that product +# Maps product IDs to lists of pull request labels that prevent changelog creation for that product # If you run the changelog add command with the --prs option and a PR has any of these labels, the changelog is not created -product_label_blockers: - # Example: Skip changelog for cloud.serverless product when PR has "Watcher" label - # cloud-serverless: - # - ":Data Management/Watcher" - # - ">non-issue" - # Example: Skip changelog creation for elasticsearch product when PR has "skip:releaseNotes" label +# Product IDs can be comma-separated to share the same list of labels across multiple products +add_blockers: + # Example: Skip changelog creation for elasticsearch product when PR has these labels # elasticsearch: # - ">non-issue" + # - ">test" + # - ">refactoring" + # Example: Share the same blockers across multiple products using comma-separated product IDs + # elasticsearch, cloud-serverless: + # - ">non-issue" diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md index a6332af2f..b3d22e657 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/release/changelog-add.md @@ -55,6 +55,7 @@ docs-builder changelog add [options...] [-h|--help] : If specified, `--title` can be derived from the PR. : If mappings are configured, `--areas` and `--type` can also be derived from the PR. : Creates one changelog file per PR. +: If `add_blockers` are configured in the changelog configuration file and a PR has a blocking label for any product in `--products`, that PR is skipped and no changelog file is created for it. `--repo ` : Optional: GitHub repository name (used when `--pr` is just a number). diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index f790a8a7e..9bf632683 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -59,6 +59,8 @@ Refer to the file layout in [changelog.yml.example](https://github.com/elastic/d You can also optionally use `add_blockers` in your changelog configuration. When you run the `docs-builder changelog add` command with the `--prs` and `--products` options and the PR has a label that you've identified as a blocker for that product, the command does not create a changelog for that PR. +You can use comma-separated product IDs to share the same list of labels across multiple products. + Refer to the file layout in [changelog.yml.example](https://github.com/elastic/docs-builder/blob/main/config/changelog.yml.example) and an [example usage](#example-block-label). ## Create changelog files [changelog-add] @@ -197,13 +199,14 @@ You can configure product-specific label blockers to prevent changelog creation If you run the `docs-builder changelog add` command with the `--prs` option and a PR has a blocking label for any of the products in the `--products` option, that PR will be skipped and no changelog file will be created for it. A warning message will be emitted indicating which PR was skipped and why. -For example, your configuration file can contain `product_label_blockers` like this: +For example, your configuration file can contain `add_blockers` like this: ```yaml # Product-specific label blockers (optional) # Maps product IDs to lists of labels that prevent changelog creation for that product # If you run the changelog add command with the --prs option and a PR has any of these labels, the changelog is not created -product_label_blockers: +# Product IDs can be comma-separated to share the same list of labels across multiple products +add_blockers: # Example: Skip changelog for cloud.serverless product when PR has "Watcher" label cloud-serverless: - ":Data Management/Watcher" @@ -211,6 +214,9 @@ product_label_blockers: # Example: Skip changelog creation for elasticsearch product when PR has "skip:releaseNotes" label elasticsearch: - ">non-issue" + # Example: Share the same blockers across multiple products using comma-separated product IDs + elasticsearch, cloud-serverless: + - ">non-issue" ``` Those settings affect commands with the `--prs` option, for example: diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs index bdead3c0b..2a6268d3c 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs @@ -60,8 +60,9 @@ public class ChangelogConfiguration /// /// Product-specific label blocking configuration /// Maps product IDs to lists of labels that should prevent changelog creation for that product + /// Keys can be comma-separated product IDs to share the same list of labels across multiple products /// - public Dictionary>? ProductLabelBlockers { get; set; } + public Dictionary>? AddBlockers { get; set; } public static ChangelogConfiguration Default => new(); } diff --git a/src/services/Elastic.Documentation.Services/ChangelogService.cs b/src/services/Elastic.Documentation.Services/ChangelogService.cs index 3e29ea4d7..976f47d4b 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -156,7 +156,7 @@ private bool ShouldSkipPrDueToLabelBlockers( string prUrl ) { - if (config.ProductLabelBlockers == null || config.ProductLabelBlockers.Count == 0) + if (config.AddBlockers == null || config.AddBlockers.Count == 0) { return false; } @@ -164,7 +164,7 @@ string prUrl foreach (var product in products) { var normalizedProductId = product.Product.Replace('_', '-'); - if (config.ProductLabelBlockers.TryGetValue(normalizedProductId, out var blockerLabels)) + if (config.AddBlockers.TryGetValue(normalizedProductId, out var blockerLabels)) { var matchingBlockerLabel = blockerLabels .FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)); @@ -408,6 +408,30 @@ Cancel ctx var config = deserializer.Deserialize(yamlContent); + // Expand comma-separated product IDs in add_blockers + if (config.AddBlockers != null && config.AddBlockers.Count > 0) + { + var expandedBlockers = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in config.AddBlockers) + { + var productKeys = kvp.Key.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var productKey in productKeys) + { + if (expandedBlockers.TryGetValue(productKey, out var existingLabels)) + { + // Merge labels if product key already exists + var mergedLabels = existingLabels.Union(kvp.Value, StringComparer.OrdinalIgnoreCase).ToList(); + expandedBlockers[productKey] = mergedLabels; + } + else + { + expandedBlockers[productKey] = kvp.Value.ToList(); + } + } + } + config.AddBlockers = expandedBlockers; + } + // Validate that changelog.yml values conform to ChangelogConfiguration defaults var defaultConfig = ChangelogConfiguration.Default; var validProductIds = configurationContext.ProductsConfiguration.Products.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -448,16 +472,16 @@ Cancel ctx } } - // Validate product_label_blockers (if specified) - product keys must be from products.yml - if (config.ProductLabelBlockers != null && config.ProductLabelBlockers.Count > 0) + // Validate add_blockers (if specified) - product keys must be from products.yml + if (config.AddBlockers != null && config.AddBlockers.Count > 0) { - foreach (var productKey in config.ProductLabelBlockers.Keys) + foreach (var productKey in config.AddBlockers.Keys) { var normalizedProductId = productKey.Replace('_', '-'); if (!validProductIds.Contains(normalizedProductId)) { var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); - collector.EmitError(finalConfigPath, $"Product '{productKey}' in product_label_blockers in changelog.yml is not in the list of available products from config/products.yml. Available products: {availableProducts}"); + collector.EmitError(finalConfigPath, $"Product '{productKey}' in add_blockers in changelog.yml is not in the list of available products from config/products.yml. Available products: {availableProducts}"); return null; } } diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index 223ae233f..ff6a4386c 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -873,7 +873,7 @@ public async Task CreateChangelog_WithInvalidType_ReturnsError() } [Fact] - public async Task CreateChangelog_WithInvalidProductInProductLabelBlockers_ReturnsError() + public async Task CreateChangelog_WithInvalidProductInAddBlockers_ReturnsError() { // Arrange var mockGitHubService = A.Fake(); @@ -889,7 +889,7 @@ public async Task CreateChangelog_WithInvalidProductInProductLabelBlockers_Retur - preview - beta - ga - product_label_blockers: + add_blockers: invalid-product: - "skip:releaseNotes" """; @@ -912,11 +912,11 @@ public async Task CreateChangelog_WithInvalidProductInProductLabelBlockers_Retur // Assert result.Should().BeFalse(); _collector.Errors.Should().BeGreaterThan(0); - _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Product 'invalid-product' in product_label_blockers") && d.Message.Contains("is not in the list of available products")); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Product 'invalid-product' in add_blockers") && d.Message.Contains("is not in the list of available products")); } [Fact] - public async Task CreateChangelog_WithValidProductInProductLabelBlockers_Succeeds() + public async Task CreateChangelog_WithValidProductInAddBlockers_Succeeds() { // Arrange var mockGitHubService = A.Fake(); @@ -932,7 +932,7 @@ public async Task CreateChangelog_WithValidProductInProductLabelBlockers_Succeed - preview - beta - ga - product_label_blockers: + add_blockers: elasticsearch: - "skip:releaseNotes" cloud-hosted: @@ -1161,7 +1161,7 @@ public async Task CreateChangelog_WithBlockingLabel_SkipsChangelogCreation() - ga label_to_type: "type:feature": feature - product_label_blockers: + add_blockers: elasticsearch: - "skip:releaseNotes" """; @@ -1224,7 +1224,7 @@ public async Task CreateChangelog_WithBlockingLabelForSpecificProduct_OnlyBlocks - ga label_to_type: "type:feature": feature - product_label_blockers: + add_blockers: cloud-serverless: - "ILM" """; @@ -1302,7 +1302,7 @@ public async Task CreateChangelog_WithMultiplePrsAndSomeBlocked_CreatesFilesForN - ga label_to_type: "type:feature": feature - product_label_blockers: + add_blockers: elasticsearch: - "skip:releaseNotes" """; @@ -1337,5 +1337,71 @@ public async Task CreateChangelog_WithMultiplePrsAndSomeBlocked_CreatesFilesForN yamlContent.Should().Contain("pr: https://github.com/elastic/elasticsearch/pull/1234"); yamlContent.Should().NotContain("Second PR with blocker"); } + + [Fact] + public async Task CreateChangelog_WithCommaSeparatedProductIdsInAddBlockers_ExpandsCorrectly() + { + // Arrange + var mockGitHubService = A.Fake(); + var prInfo = new GitHubPrInfo + { + Title = "PR with blocking label", + Labels = ["type:feature", ">non-issue"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A._, + A._, + A._, + A._)) + .Returns(prInfo); + + var fileSystem = new FileSystem(); + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + add_blockers: + elasticsearch, cloud-serverless: + - ">non-issue" + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = ["https://github.com/elastic/elasticsearch/pull/1234"], + Products = [ + new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }, + new ProductInfo { Product = "cloud-serverless", Target = "2025-08-05" } + ], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); // Should succeed but skip creating changelog due to blocker + _collector.Warnings.Should().BeGreaterThan(0); + _collector.Diagnostics.Should().Contain(d => d.Message.Contains("Skipping changelog creation") && d.Message.Contains(">non-issue")); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(0); // No files should be created + } } From 2f88f7b58d9e2e126cc25395f14b9cbe3df3e13c Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Mon, 12 Jan 2026 19:16:04 -0800 Subject: [PATCH 16/21] Add file support to docs-builder changelog add --prs option (#2453) --- docs/cli/release/changelog-add.md | 6 +- docs/contribute/changelog.md | 60 +++-- .../docs-builder/Commands/ChangelogCommand.cs | 44 +++- .../ChangelogServiceTests.cs | 234 ++++++++++++++++++ 4 files changed, 325 insertions(+), 19 deletions(-) diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md index b3d22e657..35802bb27 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/release/changelog-add.md @@ -51,7 +51,11 @@ docs-builder changelog add [options...] [-h|--help] : The valid lifecycles are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). `--prs ` -: Optional: Pull request URLs or numbers (if `--owner` and `--repo` are provided). +: Optional: Pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. +: Each occurrence can be either comma-separated PRs (e.g., `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (e.g., `--prs /path/to/file.txt`). +: When specifying PRs directly, provide comma-separated values. +: When specifying a file path, provide a single value that points to a newline-delimited file. +: If `--owner` and `--repo` are provided, PR numbers can be used instead of URLs. : If specified, `--title` can be derived from the PR. : If mappings are configured, `--areas` and `--type` can also be derived from the PR. : Creates one changelog file per PR. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index 9bf632683..80f1a9adc 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -105,6 +105,28 @@ Examples: - `"cloud-serverless 2025-08-05"` - `"cloud-enterprise 4.0.3, cloud-hosted 2025-10-31"` +### Filenames + +By default, the `docs-builder changelog add` command generates filenames using a timestamp and a sanitized version of the title: +`{timestamp}-{sanitized-title}.yaml` + +For example: `1735689600-fixes-enrich-and-lookup-join-resolution.yaml` + +If you want to use the PR number as the filename instead, add the `--use-pr-number` option: + +```sh +docs-builder changelog add \ + --pr https://github.com/elastic/elasticsearch/pull/137431 \ + --products "elasticsearch 9.2.3" \ + --use-pr-number +``` + +This creates a file named `137431.yaml` instead of the default timestamp-based filename. + +:::{important} +When using `--use-pr-number`, you must also provide the `--pr` option. The PR number is extracted from the PR URL or number you provide. +::: + ## Examples ### Create a changelog for multiple products [example-multiple-products] @@ -123,7 +145,7 @@ docs-builder changelog add \ 1. This option is required only if you want to override what's derived from the PR title. 2. The type values are defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). 3. The product values are defined in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml). -4. The `--prs` value can be a full URL (such as `https://github.com/owner/repo/pull/123`, a short format (such as `owner/repo#123`) or just a number (in which case you must also provide `--owner` and `--repo` options). Multiple PRs can be provided comma-separated, and one changelog file will be created for each PR. +4. The `--prs` value can be a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), just a number (in which case you must also provide `--owner` and `--repo` options), or a path to a file containing newline-delimited PR URLs or numbers. Multiple PRs can be provided comma-separated, or you can specify a file path. You can also mix both formats by specifying `--prs` multiple times. One changelog file will be created for each PR. The output file has the following format: @@ -231,25 +253,33 @@ docs-builder changelog add --prs "1234, 5678" \ If PR 1234 has the `>non-issue` or Watcher label, it will be skipped and no changelog will be created for it. If PR 5678 does not have any blocking labels, a changelog is created. +### Create changelogs from a file of PRs [example-file-prs] -### Filenames - -By default, the `docs-builder changelog add` command generates filenames using a timestamp and a sanitized version of the title: -`{timestamp}-{sanitized-title}.yaml` +You can also provide PRs from a file containing newline-delimited PR URLs or numbers: -For example: `1735689600-fixes-enrich-and-lookup-join-resolution.yaml` +```sh +# Create a file with PRs (one per line) +cat > prs.txt << EOF +https://github.com/elastic/elasticsearch/pull/1234 +https://github.com/elastic/elasticsearch/pull/5678 +EOF + +# Use the file with --prs +docs-builder changelog add --prs prs.txt \ + --products "elasticsearch 9.2.0 ga" \ + --config test/changelog.yml +``` -If you want to use the PR number as the filename instead, add the `--use-pr-number` option: +You can also mix file paths and comma-separated PRs: ```sh docs-builder changelog add \ - --pr https://github.com/elastic/elasticsearch/pull/137431 \ - --products "elasticsearch 9.2.3" \ - --use-pr-number + --prs "https://github.com/elastic/elasticsearch/pull/1234" \ + --prs prs.txt \ + --prs "5678, 9012" \ + --products "elasticsearch 9.2.0 ga" \ + --owner elastic --repo elasticsearch \ + --config test/changelog.yml ``` -This creates a file named `137431.yaml` instead of the default timestamp-based filename. - -:::{important} -When using `--use-pr-number`, you must also provide the `--pr` option. The PR number is extracted from the PR URL or number you provide. -::: +This creates one changelog file for each PR specified, whether from files or directly. diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index c6c152d65..3ef328577 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -2,6 +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 +using System.IO.Abstractions; using ConsoleAppFramework; using Documentation.Builder.Arguments; using Elastic.Documentation.Configuration; @@ -18,6 +19,7 @@ internal sealed class ChangelogCommand( IConfigurationContext configurationContext ) { + private readonly IFileSystem _fileSystem = new FileSystem(); /// /// Changelog commands. Use 'changelog add' to create a new changelog fragment. /// @@ -36,7 +38,7 @@ public Task Default() /// 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(s) or PR number(s) (comma-separated, or if --owner and --repo are provided, just numbers). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. + /// Optional: Pull request URL(s) or PR number(s) (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated PRs (e.g., `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (e.g., `--prs /path/to/file.txt`). When specifying PRs directly, provide comma-separated values. When specifying a file path, provide a single value that points to a newline-delimited file. If --owner and --repo are provided, PR numbers can be used instead of URLs. If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. /// Optional: GitHub repository owner (used when --prs contains just numbers) /// Optional: GitHub repository name (used when --prs contains just numbers) /// Optional: Issue URL(s) (comma-separated or specify multiple times) @@ -76,11 +78,47 @@ public async Task Create( IGitHubPrService githubPrService = new GitHubPrService(logFactory); var service = new ChangelogService(logFactory, configurationContext, githubPrService); - // Parse comma-separated PRs if provided as a single string + // Parse PRs: handle both comma-separated values and file paths string[]? parsedPrs = null; if (prs != null && prs.Length > 0) { - parsedPrs = prs.SelectMany(pr => pr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).ToArray(); + var allPrs = new List(); + foreach (var prValue in prs) + { + if (string.IsNullOrWhiteSpace(prValue)) + continue; + + var trimmedValue = prValue.Trim(); + + // Check if this is a file path + if (_fileSystem.File.Exists(trimmedValue)) + { + // Read all lines from the file (newline-delimited) + try + { + var fileLines = await _fileSystem.File.ReadAllLinesAsync(trimmedValue, ctx); + foreach (var line in fileLines) + { + if (!string.IsNullOrWhiteSpace(line)) + { + allPrs.Add(line.Trim()); + } + } + } + catch (Exception ex) + { + collector.EmitError(string.Empty, $"Failed to read PRs from file '{trimmedValue}': {ex.Message}", ex); + return 1; + } + } + else + { + // Treat as comma-separated PRs + var commaSeparatedPrs = trimmedValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + allPrs.AddRange(commaSeparatedPrs); + } + } + parsedPrs = allPrs.ToArray(); } var input = new ChangelogInput diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index ff6a4386c..295bf6769 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -1403,5 +1403,239 @@ public async Task CreateChangelog_WithCommaSeparatedProductIdsInAddBlockers_Expa var files = Directory.GetFiles(outputDir, "*.yaml"); files.Should().HaveCount(0); // No files should be created } + + [Fact] + public async Task CreateChangelog_WithPrsFromFile_ProcessesAllPrsFromFile() + { + // Arrange - Simulate what ChangelogCommand does: read PRs from a file + var mockGitHubService = A.Fake(); + var pr1Info = new GitHubPrInfo + { + Title = "First PR from file", + Labels = ["type:feature"] + }; + var pr2Info = new GitHubPrInfo + { + Title = "Second PR from file", + Labels = ["type:bug"] + }; + var pr3Info = new GitHubPrInfo + { + Title = "Third PR from file", + Labels = ["type:enhancement"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A.That.Contains("1111"), + null, + null, + A._)) + .Returns(pr1Info); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A.That.Contains("2222"), + null, + null, + A._)) + .Returns(pr2Info); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A.That.Contains("3333"), + null, + null, + A._)) + .Returns(pr3Info); + + var fileSystem = new FileSystem(); + var tempDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(tempDir); + + // Create a file with newline-delimited PRs (simulating what ChangelogCommand would read) + var prsFile = fileSystem.Path.Combine(tempDir, "prs.txt"); + var prsFileContent = """ + https://github.com/elastic/elasticsearch/pull/1111 + https://github.com/elastic/elasticsearch/pull/2222 + https://github.com/elastic/elasticsearch/pull/3333 + """; + await fileSystem.File.WriteAllTextAsync(prsFile, prsFileContent, TestContext.Current.CancellationToken); + + // Read PRs from file (simulating ChangelogCommand behavior) + var prsFromFile = await fileSystem.File.ReadAllLinesAsync(prsFile, TestContext.Current.CancellationToken); + var parsedPrs = prsFromFile + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => line.Trim()) + .ToArray(); + + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + - enhancement + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + "type:bug": bug-fix + "type:enhancement": enhancement + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = parsedPrs, // PRs read from file + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(3); // One file per PR + + var yamlContents = new List(); + foreach (var file in files) + { + var content = await File.ReadAllTextAsync(file, TestContext.Current.CancellationToken); + yamlContents.Add(content); + } + + // Verify all PRs were processed + yamlContents.Should().Contain(c => c.Contains("title: First PR from file")); + yamlContents.Should().Contain(c => c.Contains("title: Second PR from file")); + yamlContents.Should().Contain(c => c.Contains("title: Third PR from file")); + yamlContents.Should().Contain(c => c.Contains("pr: https://github.com/elastic/elasticsearch/pull/1111")); + yamlContents.Should().Contain(c => c.Contains("pr: https://github.com/elastic/elasticsearch/pull/2222")); + yamlContents.Should().Contain(c => c.Contains("pr: https://github.com/elastic/elasticsearch/pull/3333")); + } + + [Fact] + public async Task CreateChangelog_WithMixedPrsFromFileAndCommaSeparated_ProcessesAllPrs() + { + // Arrange - Simulate ChangelogCommand handling both file paths and comma-separated PRs + var mockGitHubService = A.Fake(); + var pr1Info = new GitHubPrInfo + { + Title = "PR from comma-separated", + Labels = ["type:feature"] + }; + var pr2Info = new GitHubPrInfo + { + Title = "PR from file", + Labels = ["type:bug"] + }; + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A.That.Contains("1111"), + null, + null, + A._)) + .Returns(pr1Info); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A.That.Contains("2222"), + null, + null, + A._)) + .Returns(pr2Info); + + var fileSystem = new FileSystem(); + var tempDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(tempDir); + + // Create a file with PRs + var prsFile = fileSystem.Path.Combine(tempDir, "prs.txt"); + var prsFileContent = """ + https://github.com/elastic/elasticsearch/pull/2222 + """; + await fileSystem.File.WriteAllTextAsync(prsFile, prsFileContent, TestContext.Current.CancellationToken); + + // Simulate ChangelogCommand processing: comma-separated PRs + file path + var allPrs = new List(); + + // Add comma-separated PRs + var commaSeparatedPrs = "https://github.com/elastic/elasticsearch/pull/1111".Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + allPrs.AddRange(commaSeparatedPrs); + + // Add PRs from file + var prsFromFile = await fileSystem.File.ReadAllLinesAsync(prsFile, TestContext.Current.CancellationToken); + foreach (var line in prsFromFile) + { + if (!string.IsNullOrWhiteSpace(line)) + { + allPrs.Add(line.Trim()); + } + } + + var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); + fileSystem.Directory.CreateDirectory(configDir); + var configPath = fileSystem.Path.Combine(configDir, "changelog.yml"); + var configContent = """ + available_types: + - feature + - bug-fix + available_subtypes: [] + available_lifecycles: + - preview + - beta + - ga + label_to_type: + "type:feature": feature + "type:bug": bug-fix + """; + await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken); + + var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService); + + var input = new ChangelogInput + { + Prs = allPrs.ToArray(), // Mixed PRs from comma-separated and file + Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()) + }; + + // Act + var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken); + + // Assert + result.Should().BeTrue(); + _collector.Errors.Should().Be(0); + + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!Directory.Exists(outputDir)) + Directory.CreateDirectory(outputDir); + var files = Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(2); // One file per PR + + var yamlContents = new List(); + foreach (var file in files) + { + var content = await File.ReadAllTextAsync(file, TestContext.Current.CancellationToken); + yamlContents.Add(content); + } + + // Verify both PRs were processed + yamlContents.Should().Contain(c => c.Contains("title: PR from comma-separated")); + yamlContents.Should().Contain(c => c.Contains("title: PR from file")); + yamlContents.Should().Contain(c => c.Contains("pr: https://github.com/elastic/elasticsearch/pull/1111")); + yamlContents.Should().Contain(c => c.Contains("pr: https://github.com/elastic/elasticsearch/pull/2222")); + } } From a443953a2a6830ac1924796b8c6deabb535aa47e Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 13 Jan 2026 07:28:49 -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> --- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 3ef328577..1263a9ae7 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -83,13 +83,13 @@ public async Task Create( if (prs != null && prs.Length > 0) { var allPrs = new List(); - foreach (var prValue in prs) + var validPrs = prs.Where(prValue => !string.IsNullOrWhiteSpace(prValue)); + foreach (var prValue in validPrs) { - if (string.IsNullOrWhiteSpace(prValue)) - continue; - var trimmedValue = prValue.Trim(); + // Check if this is a file path + // Check if this is a file path if (_fileSystem.File.Exists(trimmedValue)) { From 420e533ad832434de66bc5e217e24ec5d1ce26c0 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 13 Jan 2026 08:37:26 -0800 Subject: [PATCH 18/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> --- .../ChangelogServiceTests.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index 295bf6769..74b0d818a 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -1575,13 +1575,11 @@ public async Task CreateChangelog_WithMixedPrsFromFileAndCommaSeparated_Processe // Add PRs from file var prsFromFile = await fileSystem.File.ReadAllLinesAsync(prsFile, TestContext.Current.CancellationToken); - foreach (var line in prsFromFile) - { - if (!string.IsNullOrWhiteSpace(line)) - { - allPrs.Add(line.Trim()); - } - } + allPrs.AddRange( + prsFromFile + .Where(line => !string.IsNullOrWhiteSpace(line)) + .Select(line => line.Trim()) + ); var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString()); fileSystem.Directory.CreateDirectory(configDir); From 4517f1eaf4c471f1b72ec7f01b23de583a2ed895 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 13 Jan 2026 08:45:40 -0800 Subject: [PATCH 19/21] Potential fix for pull request finding 'Generic catch clause' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 1263a9ae7..47d3b5326 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -105,7 +105,7 @@ public async Task Create( } } } - catch (Exception ex) + catch (System.IO.IOException ex) { collector.EmitError(string.Empty, $"Failed to read PRs from file '{trimmedValue}': {ex.Message}", ex); return 1; From ebcae58bca36f85f1eb67929b8abd352ed8b8928 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Tue, 13 Jan 2026 09:12:11 -0800 Subject: [PATCH 20/21] Potential fix for pull request finding 'Missed opportunity to use Select' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 47d3b5326..590fc5522 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -84,9 +84,8 @@ public async Task Create( { var allPrs = new List(); var validPrs = prs.Where(prValue => !string.IsNullOrWhiteSpace(prValue)); - foreach (var prValue in validPrs) + foreach (var trimmedValue in validPrs.Select(prValue => prValue.Trim())) { - var trimmedValue = prValue.Trim(); // Check if this is a file path From 63588d8b749cf3082dfeb5dfd67add04fb4c7b23 Mon Sep 17 00:00:00 2001 From: Felipe Cotti Date: Wed, 14 Jan 2026 11:05:17 -0300 Subject: [PATCH 21/21] Small touches to ChangelogCommand --- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 590fc5522..fd4222eb8 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -80,15 +80,12 @@ public async Task Create( // Parse PRs: handle both comma-separated values and file paths string[]? parsedPrs = null; - if (prs != null && prs.Length > 0) + if (prs is { Length: > 0 }) { var allPrs = new List(); var validPrs = prs.Where(prValue => !string.IsNullOrWhiteSpace(prValue)); foreach (var trimmedValue in validPrs.Select(prValue => prValue.Trim())) { - - // Check if this is a file path - // Check if this is a file path if (_fileSystem.File.Exists(trimmedValue)) { @@ -104,7 +101,7 @@ public async Task Create( } } } - catch (System.IO.IOException ex) + catch (IOException ex) { collector.EmitError(string.Empty, $"Failed to read PRs from file '{trimmedValue}': {ex.Message}", ex); return 1;