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
6 changes: 5 additions & 1 deletion docs/cli/release/changelog-add.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ docs-builder changelog add [options...] [-h|--help]
: The valid lifecycles are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs).

`--prs <string[]?>`
: Optional: Pull request URLs or numbers (if `--owner` and `--repo` are provided).
: Optional: Pull request URLs or numbers (comma-separated), or a path to a newline-delimited file containing PR URLs or numbers. Can be specified multiple times.
: Each occurrence can be either comma-separated PRs (e.g., `--prs "https://github.com/owner/repo/pull/123,6789"`) or a file path (e.g., `--prs /path/to/file.txt`).
: When specifying PRs directly, provide comma-separated values.
: When specifying a file path, provide a single value that points to a newline-delimited file.
: If `--owner` and `--repo` are provided, PR numbers can be used instead of URLs.
: If specified, `--title` can be derived from the PR.
: If mappings are configured, `--areas` and `--type` can also be derived from the PR.
: Creates one changelog file per PR.
Expand Down
33 changes: 32 additions & 1 deletion docs/contribute/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ docs-builder changelog add \
1. This option is required only if you want to override what's derived from the PR title.
2. The type values are defined in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs).
3. The product values are defined in [products.yml](https://github.com/elastic/docs-builder/blob/main/config/products.yml).
4. The `--prs` value can be a full URL (such as `https://github.com/owner/repo/pull/123`, a short format (such as `owner/repo#123`) or just a number (in which case you must also provide `--owner` and `--repo` options). Multiple PRs can be provided comma-separated, and one changelog file will be created for each PR.
4. The `--prs` value can be a full URL (such as `https://github.com/owner/repo/pull/123`), a short format (such as `owner/repo#123`), just a number (in which case you must also provide `--owner` and `--repo` options), or a path to a file containing newline-delimited PR URLs or numbers. Multiple PRs can be provided comma-separated, or you can specify a file path. You can also mix both formats by specifying `--prs` multiple times. One changelog file will be created for each PR.

The output file has the following format:

Expand Down Expand Up @@ -227,3 +227,34 @@ docs-builder changelog add --prs "1234, 5678" \

If PR 1234 has the `>non-issue` or Watcher label, it will be skipped and no changelog will be created for it.
If PR 5678 does not have any blocking labels, a changelog is created.

### Create changelogs from a file of PRs [example-file-prs]

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.
44 changes: 41 additions & 3 deletions src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,6 +19,7 @@ internal sealed class ChangelogCommand(
IConfigurationContext configurationContext
)
{
private readonly IFileSystem _fileSystem = new FileSystem();
/// <summary>
/// Changelog commands. Use 'changelog add' to create a new changelog fragment.
/// </summary>
Expand All @@ -36,7 +38,7 @@ public Task<int> Default()
/// <param name="products">Required: Products affected in format "product target lifecycle, ..." (e.g., "elasticsearch 9.2.0 ga, cloud-serverless 2025-08-05")</param>
/// <param name="subtype">Optional: Subtype for breaking changes (api, behavioral, configuration, etc.)</param>
/// <param name="areas">Optional: Area(s) affected (comma-separated or specify multiple times)</param>
/// <param name="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.</param>
/// <param name="prs">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.</param>
/// <param name="owner">Optional: GitHub repository owner (used when --prs contains just numbers)</param>
/// <param name="repo">Optional: GitHub repository name (used when --prs contains just numbers)</param>
/// <param name="issues">Optional: Issue URL(s) (comma-separated or specify multiple times)</param>
Expand Down Expand Up @@ -74,11 +76,47 @@ public async Task<int> Create(
IGitHubPrService githubPrService = new GitHubPrService(logFactory);
var service = new ChangelogService(logFactory, configurationContext, githubPrService);

// Parse comma-separated PRs if provided as a single string
// Parse PRs: handle both comma-separated values and file paths
string[]? parsedPrs = null;
if (prs != null && prs.Length > 0)
{
parsedPrs = prs.SelectMany(pr => pr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).ToArray();
var allPrs = new List<string>();
foreach (var prValue in prs)
{
if (string.IsNullOrWhiteSpace(prValue))
continue;

var trimmedValue = prValue.Trim();

// Check if this is a file path
if (_fileSystem.File.Exists(trimmedValue))
{
// Read all lines from the file (newline-delimited)
try
{
var fileLines = await _fileSystem.File.ReadAllLinesAsync(trimmedValue, ctx);
foreach (var line in fileLines)
{
if (!string.IsNullOrWhiteSpace(line))
{
allPrs.Add(line.Trim());
}
}
}
catch (Exception ex)
{
collector.EmitError(string.Empty, $"Failed to read PRs from file '{trimmedValue}': {ex.Message}", ex);
return 1;
}
}
else
{
// Treat as comma-separated PRs
var commaSeparatedPrs = trimmedValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
allPrs.AddRange(commaSeparatedPrs);
}
}
parsedPrs = allPrs.ToArray();
}

var input = new ChangelogInput
Expand Down
234 changes: 234 additions & 0 deletions tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1403,5 +1403,239 @@ public async Task CreateChangelog_WithCommaSeparatedProductIdsInAddBlockers_Expa
var files = Directory.GetFiles(outputDir, "*.yaml");
files.Should().HaveCount(0); // No files should be created
}

[Fact]
public async Task CreateChangelog_WithPrsFromFile_ProcessesAllPrsFromFile()
{
// Arrange - Simulate what ChangelogCommand does: read PRs from a file
var mockGitHubService = A.Fake<IGitHubPrService>();
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<string>.That.Contains("1111"),
null,
null,
A<CancellationToken>._))
.Returns(pr1Info);

A.CallTo(() => mockGitHubService.FetchPrInfoAsync(
A<string>.That.Contains("2222"),
null,
null,
A<CancellationToken>._))
.Returns(pr2Info);

A.CallTo(() => mockGitHubService.FetchPrInfoAsync(
A<string>.That.Contains("3333"),
null,
null,
A<CancellationToken>._))
.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<string>();
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<IGitHubPrService>();
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<string>.That.Contains("1111"),
null,
null,
A<CancellationToken>._))
.Returns(pr1Info);

A.CallTo(() => mockGitHubService.FetchPrInfoAsync(
A<string>.That.Contains("2222"),
null,
null,
A<CancellationToken>._))
.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<string>();

// Add comma-separated PRs
var commaSeparatedPrs = "https://github.com/elastic/elasticsearch/pull/1111".Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
allPrs.AddRange(commaSeparatedPrs);

// Add PRs from file
var prsFromFile = await fileSystem.File.ReadAllLinesAsync(prsFile, TestContext.Current.CancellationToken);
foreach (var line in prsFromFile)
{
if (!string.IsNullOrWhiteSpace(line))
{
allPrs.Add(line.Trim());
}
}

var configDir = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString());
fileSystem.Directory.CreateDirectory(configDir);
var configPath = fileSystem.Path.Combine(configDir, "changelog.yml");
var configContent = """
available_types:
- feature
- bug-fix
available_subtypes: []
available_lifecycles:
- preview
- beta
- ga
label_to_type:
"type:feature": feature
"type:bug": bug-fix
""";
await fileSystem.File.WriteAllTextAsync(configPath, configContent, TestContext.Current.CancellationToken);

var service = new ChangelogService(_loggerFactory, _configurationContext, mockGitHubService);

var input = new ChangelogInput
{
Prs = allPrs.ToArray(), // Mixed PRs from comma-separated and file
Products = [new ProductInfo { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }],
Config = configPath,
Output = fileSystem.Path.Combine(fileSystem.Path.GetTempPath(), Guid.NewGuid().ToString())
};

// Act
var result = await service.CreateChangelog(_collector, input, TestContext.Current.CancellationToken);

// Assert
result.Should().BeTrue();
_collector.Errors.Should().Be(0);

var outputDir = input.Output ?? Directory.GetCurrentDirectory();
if (!Directory.Exists(outputDir))
Directory.CreateDirectory(outputDir);
var files = Directory.GetFiles(outputDir, "*.yaml");
files.Should().HaveCount(2); // One file per PR

var yamlContents = new List<string>();
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"));
}
}

Loading