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"));
+ }
}