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/cli/index.md b/docs/cli/index.md index defe1d465..044b19b21 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](#changelog-commands) ### Global options @@ -47,7 +47,7 @@ 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. diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md index 45d3e31ab..0114350fd 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/release/changelog-add.md @@ -46,10 +46,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/release/index.md index 37c29bb46..5e3ee5477 100644 --- a/docs/cli/release/index.md +++ b/docs/cli/release/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/contribute/changelog.md b/docs/contribute/changelog.md index 693217424..d0c19a430 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -1,38 +1,83 @@ -# 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). -## Command options +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. -The command supports all of the following options, which generally align with fields in the changelog schema: +If a configuration file exists, the command validates its values before generating changelog files: -```sh -Usage: changelog add [options...] [-h|--help] [--version] +- 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 -Add a new changelog fragment from command-line input +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 + +You can also optionally add `product_label_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] - --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] @@ -57,37 +102,7 @@ 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. -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 `--pr` 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 - -### 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: @@ -97,13 +112,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: @@ -120,7 +135,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: @@ -135,7 +150,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: @@ -150,13 +165,16 @@ label_to_areas: ":Search Relevance/ES|QL": "ES|QL" ``` -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: +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 @@ -168,3 +186,38 @@ areas: - ES|QL title: '[ES|QL] Take TOP_SNIPPETS out of snapshot' ``` + +### 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. + +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: + +```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: + - ":Data Management/Watcher" + - ">non-issue" + # Example: Skip changelog creation for elasticsearch product when PR has "skip:releaseNotes" label + elasticsearch: + - ">non-issue" +``` + +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 +``` + +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. 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 86d4dce98..4b0741cd6 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 f2c96226b..35c1536fd 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -40,185 +40,316 @@ 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); } - // If PR is specified, try to fetch PR information and derive title/type - if (!string.IsNullOrWhiteSpace(input.Pr)) - { - 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; - } + // 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; + } + } - // 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"); - } + private async Task CreateChangelogsForMultiplePrs( + IDiagnosticsCollector collector, + ChangelogInput input, + ChangelogConfiguration config, + Cancel ctx + ) + { + if (input.Prs == null || input.Prs.Length == 0) + { + return false; + } - // 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; - } + // 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 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"); - } + var successCount = 0; + var skippedCount = 0; - // 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) - { - 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"); - } - } + foreach (var prTrimmed in input.Prs.Select(pr => pr.Trim()).Where(prTrimmed => !string.IsNullOrWhiteSpace(prTrimmed))) + { - // Validate required fields (must be provided either explicitly or derived from PR) - if (string.IsNullOrWhiteSpace(input.Title)) + // Fetch PR information + var prInfo = await TryFetchPrInfoAsync(prTrimmed, input.Owner, input.Repo, ctx); + if (prInfo == null) { - collector.EmitError(string.Empty, "Title is required. Provide --title or specify --pr to derive it from the PR."); - return false; + collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prTrimmed}. Skipping this PR."); + continue; } - if (string.IsNullOrWhiteSpace(input.Type)) + // Check for label blockers + var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prTrimmed); + if (shouldSkip) { - 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; + skippedCount++; + continue; } - if (input.Products.Count == 0) + // Create a copy of input for this PR + var prInput = new ChangelogInput { - collector.EmitError(string.Empty, "At least one product is required"); - return false; + 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++; } + } + + if (successCount == 0 && skippedCount == 0) + { + return false; + } + + _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; + } - // Validate type is in allowed list - if (!config.AvailableTypes.Contains(input.Type)) + foreach (var product in products) + { + var normalizedProductId = product.Product.Replace('_', '-'); + if (config.ProductLabelBlockers.TryGetValue(normalizedProductId, out var blockerLabels)) { - collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); - return false; + var matchingBlockerLabel = blockerLabels + .FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)); + if (matchingBlockerLabel != null) + { + 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; + } } + } + + 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; + + // 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; + } - // Validate subtype if provided - if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.AvailableSubtypes.Contains(input.Subtype)) + // 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, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); + collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {prUrl}. Cannot derive title and type."); return false; } - // Validate areas if configuration provides available areas - if (config.AvailableAreas != null && config.AvailableAreas.Count > 0 && input.Areas != null) + // Use PR title if title was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Title)) { - foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area))) + if (string.IsNullOrWhiteSpace(prInfo.Title)) { - collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}"); + 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 + { + _logger.LogDebug("Using explicitly provided title, ignoring PR title"); } - // Always validate products against products.yml - var validProductIds = configurationContext.ProductsConfiguration.Products.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase); - foreach (var product in input.Products) + // Map labels to type if type was not explicitly provided + if (string.IsNullOrWhiteSpace(input.Type)) { - // Normalize product ID (replace underscores with hyphens for comparison) - var normalizedProductId = product.Product.Replace('_', '-'); - if (!validProductIds.Contains(normalizedProductId)) + if (config.LabelToType == null || config.LabelToType.Count == 0) { - 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}"); + 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; } - } - // Validate lifecycle values in products - foreach (var product in input.Products.Where(product => !string.IsNullOrWhiteSpace(product.Lifecycle) && !config.AvailableLifecycles.Contains(product.Lifecycle))) + 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 { - 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; + _logger.LogDebug("Using explicitly provided type, ignoring PR labels"); } - // 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)) + // 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) + { + input.Areas = mappedAreas.ToArray(); + _logger.LogInformation("Mapped PR labels to areas: {Areas}", string.Join(", ", mappedAreas)); + } + } + else if (input.Areas != null && input.Areas.Length > 0) { - _ = _fileSystem.Directory.CreateDirectory(outputDir); + _logger.LogDebug("Using explicitly provided areas, ignoring PR labels"); } + } - // 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); + // 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( @@ -286,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) @@ -305,7 +451,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 @@ -318,7 +464,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 7c108441d..2fe3447df 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 @@ -55,7 +55,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, @@ -74,6 +74,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, @@ -81,7 +88,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 c826f66ab..18bad528d 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()) @@ -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()) @@ -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" }], @@ -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() { @@ -822,5 +916,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"); + } }