diff --git a/config/changelog.yml.example b/config/changelog.yml.example index 5d44b20ba..9091fa0be 100644 --- a/config/changelog.yml.example +++ b/config/changelog.yml.example @@ -76,3 +76,17 @@ 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 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 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-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/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 d17b613f3..35802bb27 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/release/changelog-add.md @@ -50,10 +50,16 @@ 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 (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. +: 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/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 31024aa2a..80f1a9adc 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -1,38 +1,85 @@ -# 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 + +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 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). -Add a new changelog fragment from command-line input +## 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] @@ -58,44 +105,14 @@ 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 - ### Filenames -By default, the command generates filenames using a timestamp and a sanitized version of the title: +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, use the `--use-pr-number` option: +If you want to use the PR number as the filename instead, add the `--use-pr-number` option: ```sh docs-builder changelog add \ @@ -110,7 +127,9 @@ This creates a file named `137431.yaml` instead of the default timestamp-based f 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. ::: -### Multiple products +## Examples + +### Create a changelog for multiple products [example-multiple-products] The following command creates a changelog for a bug fix that applies to two products: @@ -120,13 +139,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`), 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: @@ -143,7 +162,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: @@ -158,7 +177,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: @@ -173,13 +192,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 @@ -191,3 +213,73 @@ 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 `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 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" + - ">non-issue" + # 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: + +```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. + +### Create changelogs from a file of PRs [example-file-prs] + +You can also provide PRs from a file containing newline-delimited PR URLs or numbers: + +```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 +``` + +You can also mix file paths and comma-separated PRs: + +```sh +docs-builder changelog add \ + --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 one changelog file for each PR specified, whether from files or directly. diff --git a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs index 90f08db0e..2a6268d3c 100644 --- a/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs +++ b/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs @@ -57,6 +57,13 @@ 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 + /// Keys can be comma-separated product IDs to share the same list of labels across multiple products + /// + public Dictionary>? AddBlockers { 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..976f47d4b 100644 --- a/src/services/Elastic.Documentation.Services/ChangelogService.cs +++ b/src/services/Elastic.Documentation.Services/ChangelogService.cs @@ -40,30 +40,183 @@ 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 (more than one PR) + if (input.Prs != null && input.Prs.Length > 1) { - 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 prTrimmed in input.Prs.Select(pr => pr.Trim()).Where(prTrimmed => !string.IsNullOrWhiteSpace(prTrimmed))) + { + + // 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; + // 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."); + } + else + { + // 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 + 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++; } + } + + if (successCount == 0 && skippedCount == 0) + { + return false; + } + + _logger.LogInformation("Processed {SuccessCount} PR(s) successfully, skipped {SkippedCount} PR(s)", successCount, skippedCount); + return successCount > 0; + } - // If PR is specified, try to fetch PR information and derive title/type - if (!string.IsNullOrWhiteSpace(input.Pr)) + private bool ShouldSkipPrDueToLabelBlockers( + string[] prLabels, + List products, + ChangelogConfiguration config, + IDiagnosticsCollector collector, + string prUrl + ) + { + if (config.AddBlockers == null || config.AddBlockers.Count == 0) + { + return false; + } + + foreach (var product in products) + { + var normalizedProductId = product.Product.Replace('_', '-'); + if (config.AddBlockers.TryGetValue(normalizedProductId, out var blockerLabels)) { - var prInfo = await TryFetchPrInfoAsync(input.Pr, input.Owner, input.Repo, ctx); - if (prInfo == null) + var matchingBlockerLabel = blockerLabels + .FirstOrDefault(blockerLabel => prLabels.Contains(blockerLabel, StringComparer.OrdinalIgnoreCase)); + if (matchingBlockerLabel != null) { - collector.EmitError(string.Empty, $"Failed to fetch PR information from GitHub for PR: {input.Pr}. Cannot derive title and type."); - return false; + 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; + var prFetchFailed = false; + + // 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) + { + // 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."); + } + else + { + // Check for label blockers (only if we successfully fetched PR info) + var shouldSkip = ShouldSkipPrDueToLabelBlockers(prInfo.Labels, input.Products, config, collector, prUrl); + if (shouldSkip) + { + // Return true but don't create changelog (similar to multiple PRs behavior) + return true; } // Use PR title if title was not explicitly provided @@ -71,7 +224,7 @@ Cancel ctx { 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."); + 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; @@ -87,7 +240,7 @@ Cancel ctx { 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."); + 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; } @@ -95,7 +248,7 @@ Cancel ctx 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."); + 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; @@ -107,7 +260,7 @@ Cancel ctx } // Map labels to areas if areas were not explicitly provided - if ((input.Areas == null || 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) @@ -121,126 +274,113 @@ Cancel ctx _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)) + // 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)) + { + if (prFetchFailed) { - collector.EmitError(string.Empty, "Title is required. Provide --title or specify --pr to derive it from the PR."); - return false; + collector.EmitWarning(string.Empty, "Title is missing. The changelog will be created with title commented out. Please manually update the title field."); } - - if (string.IsNullOrWhiteSpace(input.Type)) + else { - 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)."); + collector.EmitError(string.Empty, "Title is required. Provide --title or specify --prs to derive it from the PR."); return false; } + } - if (input.Products.Count == 0) + if (string.IsNullOrWhiteSpace(input.Type)) + { + if (prFetchFailed) { - collector.EmitError(string.Empty, "At least one product is required"); - return false; + collector.EmitWarning(string.Empty, "Type is missing. The changelog will be created with type commented out. Please manually update the type field."); } - - // Validate type is in allowed list - if (!config.AvailableTypes.Contains(input.Type)) + else { - collector.EmitError(string.Empty, $"Type '{input.Type}' is not in the list of available types. Available types: {string.Join(", ", config.AvailableTypes)}"); + 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; } + } - // Validate subtype if provided - if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.AvailableSubtypes.Contains(input.Subtype)) - { - collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); - return false; - } + if (input.Products.Count == 0) + { + collector.EmitError(string.Empty, "At least one product is required"); + return false; + } - // Validate areas if configuration provides available areas - if (config.AvailableAreas != null && config.AvailableAreas.Count > 0 && input.Areas != null) - { - foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area))) - { - collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}"); - return false; - } - } + // Validate 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; + } - // 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 subtype if provided + if (!string.IsNullOrWhiteSpace(input.Subtype) && !config.AvailableSubtypes.Contains(input.Subtype)) + { + collector.EmitError(string.Empty, $"Subtype '{input.Subtype}' is not in the list of available subtypes. Available subtypes: {string.Join(", ", config.AvailableSubtypes)}"); + return false; + } - // Validate lifecycle values in products - foreach (var product in input.Products.Where(product => !string.IsNullOrWhiteSpace(product.Lifecycle) && !config.AvailableLifecycles.Contains(product.Lifecycle))) + // Validate areas if configuration provides available areas + if (config.AvailableAreas != null && config.AvailableAreas.Count > 0 && input.Areas != null) + { + foreach (var area in input.Areas.Where(area => !config.AvailableAreas.Contains(area))) { - 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)}"); + collector.EmitError(string.Empty, $"Area '{area}' is not in the list of available areas. Available areas: {string.Join(", ", config.AvailableAreas)}"); return false; } + } - // Build changelog data from input - var changelogData = BuildChangelogData(input); - - // Generate YAML file - var yamlContent = GenerateYaml(changelogData, config); - - // Determine output path - var outputDir = input.Output ?? Directory.GetCurrentDirectory(); - if (!_fileSystem.Directory.Exists(outputDir)) - { - _ = _fileSystem.Directory.CreateDirectory(outputDir); - } - - // Generate filename - string filename; - if (input.UsePrNumber) - { - var prNumber = ExtractPrNumber(input.Pr!, input.Owner, input.Repo); - if (prNumber == null) - { - collector.EmitError(string.Empty, $"Unable to extract PR number from '{input.Pr}'. Cannot use --use-pr-number option."); - return false; - } - filename = $"{prNumber}.yaml"; - } - else + // 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)) { - // Generate filename (timestamp-slug.yaml) - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); - var slug = SanitizeFilename(input.Title); - filename = $"{timestamp}-{slug}.yaml"; + 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; } - var filePath = _fileSystem.Path.Combine(outputDir, filename); - - // Write file - await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); - _logger.LogInformation("Created changelog fragment: {FilePath}", filePath); - - return true; } - catch (OperationCanceledException) - { - // If cancelled, don't emit error; propagate cancellation signal. - throw; - } - catch (IOException ioEx) + + // 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, $"IO error creating changelog: {ioEx.Message}", ioEx); + 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; } - catch (UnauthorizedAccessException uaEx) + + // Build changelog data from input + var changelogData = BuildChangelogData(input, prUrl); + + // Generate YAML file + var yamlContent = GenerateYaml(changelogData, config, string.IsNullOrWhiteSpace(input.Title), string.IsNullOrWhiteSpace(input.Type)); + + // Determine output path + var outputDir = input.Output ?? Directory.GetCurrentDirectory(); + if (!_fileSystem.Directory.Exists(outputDir)) { - collector.EmitError(string.Empty, $"Access denied creating changelog: {uaEx.Message}", uaEx); - return false; + _ = _fileSystem.Directory.CreateDirectory(outputDir); } + + // Generate filename (timestamp-slug.yaml) + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + 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); + + // Write file + await _fileSystem.File.WriteAllTextAsync(filePath, yamlContent, ctx); + _logger.LogInformation("Created changelog fragment: {FilePath}", filePath); + + return true; } private async Task LoadChangelogConfiguration( @@ -268,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); @@ -308,6 +472,21 @@ Cancel ctx } } + // 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.AddBlockers.Keys) + { + var normalizedProductId = productKey.Replace('_', '-'); + if (!validProductIds.Contains(normalizedProductId)) + { + var availableProducts = string.Join(", ", validProductIds.OrderBy(p => p)); + 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; + } + } + } + return config; } catch (IOException ex) @@ -327,20 +506,20 @@ 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 + // 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, 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 }; @@ -357,7 +536,7 @@ private static ChangelogData BuildChangelogData(ChangelogInput input) 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) @@ -367,6 +546,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) @@ -374,6 +565,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/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 335ccf0c0..fd4222eb8 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,9 +38,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 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) /// Optional: Additional information about the change (max 600 characters) /// Optional: How the user's environment is affected @@ -56,7 +58,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 +78,45 @@ public async Task Create( IGitHubPrService githubPrService = new GitHubPrService(logFactory); var service = new ChangelogService(logFactory, configurationContext, githubPrService); + // Parse PRs: handle both comma-separated values and file paths + string[]? parsedPrs = null; + 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 + 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 (IOException 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 { Title = title, @@ -83,7 +124,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..74b0d818a 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() }; @@ -191,7 +199,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 +274,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 +346,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 +400,7 @@ public async Task CreateChangelog_WithPrNumberAndOwnerRepo_FetchesPrInfo() var input = new ChangelogInput { - Pr = "12345", + Prs = ["12345"], Owner = "elastic", Repo = "elasticsearch", Title = "Update documentation", @@ -439,7 +447,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 +654,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()) @@ -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(); @@ -679,7 +687,9 @@ 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"], + 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] @@ -743,6 +872,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_WithInvalidProductInAddBlockers_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 + add_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 add_blockers") && d.Message.Contains("is not in the list of available products")); + } + + [Fact] + public async Task CreateChangelog_WithValidProductInAddBlockers_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 + add_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 +1045,595 @@ 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( + A.That.Contains("1234"), + null, + null, + A._)) + .Returns(pr1Info); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A.That.Contains("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 + add_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 + add_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( + A.That.Contains("1234"), + null, + null, + A._)) + .Returns(pr1Info); + + A.CallTo(() => mockGitHubService.FetchPrInfoAsync( + A.That.Contains("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 + add_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"); + } + + [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 + } + + [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); + 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); + 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")); + } }