From a84876b96d8368b6577769ca163c1395ea4d0162 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 12 Jan 2026 17:50:07 -0800 Subject: [PATCH 1/2] Add file support to docs-builder changelog add --prs option --- docs/cli/release/changelog-add.md | 6 +- docs/contribute/changelog.md | 33 ++- .../docs-builder/Commands/ChangelogCommand.cs | 44 +++- .../ChangelogServiceTests.cs | 234 ++++++++++++++++++ 4 files changed, 312 insertions(+), 5 deletions(-) diff --git a/docs/cli/release/changelog-add.md b/docs/cli/release/changelog-add.md index aa039e383..ab45efccb 100644 --- a/docs/cli/release/changelog-add.md +++ b/docs/cli/release/changelog-add.md @@ -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 ` -: 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. diff --git a/docs/contribute/changelog.md b/docs/contribute/changelog.md index e09fc4532..b5cf3ccbb 100644 --- a/docs/contribute/changelog.md +++ b/docs/contribute/changelog.md @@ -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: @@ -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. diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 2fe3447df..57998767c 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -9,6 +9,7 @@ using Elastic.Documentation.Services; using Elastic.Documentation.Services.Changelog; using Microsoft.Extensions.Logging; +using System.IO.Abstractions; namespace Documentation.Builder.Commands; @@ -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,7 +38,7 @@ 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(s) or PR number(s) (comma-separated, or if --owner and --repo are provided, just numbers). If specified, --title can be derived from the PR. If mappings are configured, --areas and --type can also be derived from the PR. Creates one changelog file per PR. + /// Optional: 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) @@ -74,11 +76,47 @@ public async Task Create( IGitHubPrService githubPrService = new GitHubPrService(logFactory); var service = new ChangelogService(logFactory, configurationContext, githubPrService); - // Parse comma-separated PRs if provided as a single string + // 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(); + 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 diff --git a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs index ff6a4386c..295bf6769 100644 --- a/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs +++ b/tests/Elastic.Documentation.Services.Tests/ChangelogServiceTests.cs @@ -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(); + 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); + 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(); + 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")); + } } From 1296d6b545687bc13850396c3d42039eb5aa62b6 Mon Sep 17 00:00:00 2001 From: lcawl Date: Mon, 12 Jan 2026 18:28:34 -0800 Subject: [PATCH 2/2] Lint --- src/tooling/docs-builder/Commands/ChangelogCommand.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 57998767c..253a5423c 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; @@ -9,7 +10,6 @@ using Elastic.Documentation.Services; using Elastic.Documentation.Services.Changelog; using Microsoft.Extensions.Logging; -using System.IO.Abstractions; namespace Documentation.Builder.Commands;