Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/cli/release/changelog-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ docs-builder changelog bundle [options...] [-h|--help]
: Optional: The GitHub repository owner, which is required when pull requests are specified as numbers.

`--prs <string[]?>`
: Filter by pull request URLs or numbers (can specify multiple times).

`--prs-file <string?>`
: The path to a newline-delimited file containing PR URLs or numbers.
: Filter by 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.

`--repo <string?>`
: Optional: The GitHub repository name, which is required when PRs are specified as numbers.
Expand Down
33 changes: 17 additions & 16 deletions docs/contribute/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ Options:
--input-products <List<ProductInfo>?> Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02, cloud-serverless 2025-12-06") [Default: null]
--output-products <List<ProductInfo>?> Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs. [Default: null]
--resolve Copy the contents of each changelog file into the entries array
--prs <string[]?> Filter by pull request URLs or numbers (can specify multiple times) [Default: null]
--prs-file <string?> Path to a newline-delimited file containing PR URLs or numbers [Default: null]
--prs <string[]?> Filter by 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. [Default: null]
--owner <string?> Optional: GitHub repository owner (used when PRs are specified as numbers) [Default: null]
--repo <string?> Optional: GitHub repository name (used when PRs are specified as numbers) [Default: null]
```
Expand All @@ -124,12 +123,11 @@ You can specify only one of the following filter options:
: For example, `"cloud-serverless 2025-12-02, cloud-serverless 2025-12-06"`.

`--prs`
: Include changelogs for the specified pull request URLs or numbers.
: Pull requests can be identified by 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).

`--prs-file`
: Include changelogs for the pull request URLs or numbers specified in a newline-delimited file.
: Pull requests can be identified by 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).
: Include changelogs for the specified pull request URLs or numbers, 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,12345"`) 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. The file should contain one PR URL or number per line.
: Pull requests can be identified by 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).

By default, the output file contains only the changelog file names and checksums.
You can optionally use the `--resolve` command option to pull all of the content from each changelog into the bundle.
Expand Down Expand Up @@ -171,13 +169,13 @@ If you add the `--resolve` option, the contents of each changelog will be includ
You can use the `--prs` option (with the `--repo` and `--owner` options if you provide only the PR numbers) to create a bundle of the changelogs that relate to those pull requests:

```sh
docs-builder changelog bundle --prs 108875,135873,136886 \ <1>
docs-builder changelog bundle --prs "108875,135873,136886" \ <1>
--repo elasticsearch \ <2>
--owner elastic \ <3>
--output-products "elasticsearch 9.2.2" <4>
```

1. The list of pull request numbers to seek.
1. The comma-separated list of pull request numbers to seek. You can also specify multiple `--prs` options, each with comma-separated PRs or a file path.
2. The repository in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option.
3. The owner in the pull request URLs. This option is not required if you specify the short or full PR URLs in the `--prs` option.
4. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values.
Expand Down Expand Up @@ -213,17 +211,20 @@ https://github.com/elastic/elasticsearch/pull/136886
https://github.com/elastic/elasticsearch/pull/137126
```

You can use the `--prs-file` option to create a bundle of the changelogs that relate to those pull requests:
You can use the `--prs` option with a file path to create a bundle of the changelogs that relate to those pull requests. You can also combine multiple `--prs` options:

```sh
./docs-builder changelog bundle --prs-file test/9.2.2.txt \ <1>
./docs-builder changelog bundle \
--prs "https://github.com/elastic/elasticsearch/pull/108875,135873" \ <1>
--prs test/9.2.2.txt \ <2>
--output-products "elasticsearch 9.2.2" <3>
--resolve <3>
--resolve <4>
```

1. The path for the file that lists the pull requests. If the file contains only PR numbers, you must add `--repo` and `--owner` command options.
2. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values.
3. Optionally include the contents of each changelog in the output file.
1. Comma-separated list of pull request URLs or numbers.
2. The path for the file that lists the pull requests. If the file contains only PR numbers, you must add `--repo` and `--owner` command options.
3. The product metadata for the bundle. If it is not provided, it will be derived from all the changelog product values.
4. Optionally include the contents of each changelog in the output file.

If you have changelog files that reference those pull requests, the command creates a file like this:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ public class ChangelogBundleInput
public List<ProductInfo>? OutputProducts { get; set; }
public bool Resolve { get; set; }
public string[]? Prs { get; set; }
public string? PrsFile { get; set; }
public string? Owner { get; set; }
public string? Repo { get; set; }
}
Expand Down
173 changes: 137 additions & 36 deletions src/services/Elastic.Documentation.Services/ChangelogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -546,55 +546,145 @@ Cancel ctx
filterCount++;
if (input.Prs is { Length: > 0 })
filterCount++;
if (!string.IsNullOrWhiteSpace(input.PrsFile))
filterCount++;

if (filterCount == 0)
{
collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, --prs, or --prs-file");
collector.EmitError(string.Empty, "At least one filter option must be specified: --all, --input-products, or --prs");
return false;
}

if (filterCount > 1)
{
collector.EmitError(string.Empty, "Only one filter option can be specified at a time: --all, --input-products, --prs, or --prs-file");
collector.EmitError(string.Empty, "Only one filter option can be specified at a time: --all, --input-products, or --prs");
return false;
}

// Load PRs from file if specified
// Load PRs - check if --prs contains a file path or a list of PRs
var prsToMatch = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(input.PrsFile))
if (input.Prs is { Length: > 0 })
{
if (!_fileSystem.File.Exists(input.PrsFile))
// If there's exactly one value, check if it's a file path
if (input.Prs.Length == 1)
{
collector.EmitError(input.PrsFile, "PRs file does not exist");
return false;
}
var singleValue = input.Prs[0];

var prsFileContent = await _fileSystem.File.ReadAllTextAsync(input.PrsFile, ctx);
var prsFromFile = prsFileContent
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToArray();
// Check if it's a URL - URLs should always be treated as PRs, not file paths
var isUrl = singleValue.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
singleValue.StartsWith("https://", StringComparison.OrdinalIgnoreCase);

if (input.Prs is { Length: > 0 })
{
foreach (var pr in input.Prs)
if (isUrl)
{
_ = prsToMatch.Add(pr);
// Treat as PR identifier
_ = prsToMatch.Add(singleValue);
}
}
else if (_fileSystem.File.Exists(singleValue))
{
// File exists, read PRs from it
var prsFileContent = await _fileSystem.File.ReadAllTextAsync(singleValue, ctx);
var prsFromFile = prsFileContent
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToArray();

foreach (var pr in prsFromFile)
{
_ = prsToMatch.Add(pr);
}
}
else
{
// Check if it looks like a file path (contains path separators or has extension)
var looksLikeFilePath = singleValue.Contains(_fileSystem.Path.DirectorySeparatorChar) ||
singleValue.Contains(_fileSystem.Path.AltDirectorySeparatorChar) ||
_fileSystem.Path.HasExtension(singleValue);

foreach (var pr in prsFromFile)
{
_ = prsToMatch.Add(pr);
if (looksLikeFilePath)
{
// File path doesn't exist - if there are no other PRs, return error; otherwise emit warning
if (prsToMatch.Count == 0)
{
collector.EmitError(singleValue, $"File does not exist: {singleValue}");
return false;
}
else
{
collector.EmitWarning(singleValue, $"File does not exist, skipping: {singleValue}");
}
}
else
{
// Doesn't look like a file path, treat as PR identifier
_ = prsToMatch.Add(singleValue);
}
}
}
}
else if (input.Prs is { Length: > 0 })
{
foreach (var pr in input.Prs)
else
{
_ = prsToMatch.Add(pr);
// Multiple values - process all values first, then check for errors
var nonExistentFiles = new List<string>();
foreach (var value in input.Prs)
{
// Check if it's a URL - URLs should always be treated as PRs
var isUrl = value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("https://", StringComparison.OrdinalIgnoreCase);

if (isUrl)
{
// Treat as PR identifier
_ = prsToMatch.Add(value);
}
else if (_fileSystem.File.Exists(value))
{
// File exists, read PRs from it
var prsFileContent = await _fileSystem.File.ReadAllTextAsync(value, ctx);
var prsFromFile = prsFileContent
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(p => !string.IsNullOrWhiteSpace(p))
.ToArray();

foreach (var pr in prsFromFile)
{
_ = prsToMatch.Add(pr);
}
}
else
{
// Check if it looks like a file path
var looksLikeFilePath = value.Contains(_fileSystem.Path.DirectorySeparatorChar) ||
value.Contains(_fileSystem.Path.AltDirectorySeparatorChar) ||
_fileSystem.Path.HasExtension(value);

if (looksLikeFilePath)
{
// Track non-existent files to check later
nonExistentFiles.Add(value);
}
else
{
// Doesn't look like a file path, treat as PR identifier
_ = prsToMatch.Add(value);
}
}
}

// After processing all values, handle non-existent files
if (nonExistentFiles.Count > 0)
{
// If there are no valid PRs and we have non-existent files, return error
if (prsToMatch.Count == 0)
{
collector.EmitError(nonExistentFiles[0], $"File does not exist: {nonExistentFiles[0]}");
return false;
}
else
{
// Emit warnings for non-existent files since we have valid PRs
foreach (var file in nonExistentFiles)
{
collector.EmitWarning(file, $"File does not exist, skipping: {file}");
}
}
}
}
}

Expand Down Expand Up @@ -766,12 +856,6 @@ Cancel ctx
}
}

if (changelogEntries.Count == 0)
{
collector.EmitError(string.Empty, "No changelog entries matched the filter criteria");
return false;
}

_logger.LogInformation("Found {Count} matching changelog entries", changelogEntries.Count);

// Build bundled data
Expand Down Expand Up @@ -805,7 +889,7 @@ Cancel ctx
.ToList();
}
// Otherwise, extract unique products/versions from changelog entries
else
else if (changelogEntries.Count > 0)
{
var productVersions = new HashSet<(string product, string version)>();
foreach (var (data, _, _, _) in changelogEntries)
Expand All @@ -827,6 +911,18 @@ Cancel ctx
})
.ToList();
}
else
{
// No entries and no products specified - initialize to empty list
bundledData.Products = [];
}

// Check if we should allow empty result
if (changelogEntries.Count == 0)
{
collector.EmitError(string.Empty, "No changelog entries matched the filter criteria");
return false;
}

// Check for products with same product ID but different versions
var productsByProductId = bundledData.Products.GroupBy(p => p.Product, StringComparer.OrdinalIgnoreCase)
Expand All @@ -840,7 +936,12 @@ Cancel ctx
}

// Build entries
if (input.Resolve)
if (changelogEntries.Count == 0)
{
// No entries - initialize to empty list
bundledData.Entries = [];
}
else if (input.Resolve)
{
// When resolving, include changelog contents and validate required fields
var resolvedEntries = new List<BundledEntry>();
Expand Down
32 changes: 27 additions & 5 deletions src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelog(collector, st
/// <param name="inputProducts">Filter by products in format "product target lifecycle, ..." (e.g., "cloud-serverless 2025-12-02, cloud-serverless 2025-12-06")</param>
/// <param name="outputProducts">Explicitly set the products array in the output file in format "product target lifecycle, ...". Overrides any values from changelogs.</param>
/// <param name="resolve">Copy the contents of each changelog file into the entries array</param>
/// <param name="prs">Filter by pull request URLs or numbers (can specify multiple times)</param>
/// <param name="prsFile">Path to a newline-delimited file containing PR URLs or numbers</param>
/// <param name="prs">Filter by 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.</param>
/// <param name="owner">Optional: GitHub repository owner (used when PRs are specified as numbers)</param>
/// <param name="repo">Optional: GitHub repository name (used when PRs are specified as numbers)</param>
/// <param name="ctx"></param>
Expand All @@ -124,7 +123,6 @@ public async Task<int> Bundle(
[ProductInfoParser] List<ProductInfo>? outputProducts = null,
bool resolve = false,
string[]? prs = null,
string? prsFile = null,
string? owner = null,
string? repo = null,
Cancel ctx = default
Expand All @@ -134,6 +132,31 @@ public async Task<int> Bundle(

var service = new ChangelogService(logFactory, configurationContext, null);

// Process each --prs occurrence: each can be comma-separated PRs or a file path
var allPrs = new List<string>();
if (prs is { Length: > 0 })
{
foreach (var prsValue in prs)
{
if (string.IsNullOrWhiteSpace(prsValue))
continue;

// Check if it contains commas - if so, split and add each as a PR
if (prsValue.Contains(','))
{
var commaSeparatedPrs = prsValue
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(p => !string.IsNullOrWhiteSpace(p));
allPrs.AddRange(commaSeparatedPrs);
}
else
{
// Single value - pass as-is (will be handled by service layer as file path or PR)
allPrs.Add(prsValue);
}
}
}

var input = new ChangelogBundleInput
{
Directory = directory ?? Directory.GetCurrentDirectory(),
Expand All @@ -142,8 +165,7 @@ public async Task<int> Bundle(
InputProducts = inputProducts,
OutputProducts = outputProducts,
Resolve = resolve,
Prs = prs,
PrsFile = prsFile,
Prs = allPrs.Count > 0 ? allPrs.ToArray() : null,
Owner = owner,
Repo = repo
};
Expand Down
Loading
Loading