diff --git a/docs/cli/changelog/add.md b/docs/cli/changelog/add.md index d700cd8016..710fcb9f2f 100644 --- a/docs/cli/changelog/add.md +++ b/docs/cli/changelog/add.md @@ -30,7 +30,7 @@ docs-builder changelog add [options...] [-h|--help] `--no-extract-release-notes` : Optional: Turn off extraction of release notes from PR or issue descriptions. -: By default, the behavior is determined by the [extract.release_notes](/contribute/configure-changelogs-ref.md#extract) changelog configuration setting. +: By default, the behavior is determined by the [extract.release_notes](/contribute/configure-changelogs-ref.md#extract) changelog configuration setting. Release notes are extracted when using `--prs` or `--report` (and from issues when using `--issues`). `--feature-id ` : Optional: Feature flag ID @@ -50,10 +50,11 @@ docs-builder changelog add [options...] [-h|--help] : If `--owner` and `--repo` are provided, issue numbers can be used instead of URLs. : If specified, `--title` can be derived from the issue. : Creates one changelog file per issue. +: Mutually exclusive with `--report`. `--no-extract-issues` : Optional: Turn off extraction of linked references. -: When using `--prs`: turns off extraction of linked issues from the PR body (for example, "Fixes #123"). +: When using `--prs` or `--report`: turns off extraction of linked issues from the PR body (for example, "Fixes #123"). : When using `--issues`: turns off extraction of linked PRs from the issue body (for example, "Fixed by #123"). : By default, the behavior is determined by the `extract.issues` changelog configuration setting. @@ -61,7 +62,7 @@ docs-builder changelog add [options...] [-h|--help] : Optional: Output directory for the changelog fragment. Falls back to `bundle.directory` in `changelog.yml` when not specified. If that value is also absent, defaults to current directory. `--owner ` -: Optional: GitHub repository owner (used when `--prs` or `--issues` contains just numbers, or when using `--release-version`). +: Optional: GitHub repository owner (used when `--prs` or `--issues` contains just numbers, or when using `--release-version`). Not required when `--prs` or `--report` supplies only fully-qualified pull request URLs. : Falls back to `bundle.owner` in `changelog.yml` when not specified. If that value is also absent, defaults to `elastic`. `--products >` @@ -80,19 +81,26 @@ docs-builder changelog add [options...] [-h|--help] : If mappings are configured, `--areas`, `--type`, and `--products` can also be derived from the PR labels. : Creates one changelog file per PR. : If there are `rules.create` definitions in the changelog configuration file and a PR has a blocking label for the resolved products, that PR is skipped and no changelog file is created for it. +: Mutually exclusive with `--report`. +`--report ` +: Optional: URL or path to a promotion report HTML document (for example a Buildkite promotion report). The command extracts GitHub pull request URLs from the HTML and creates one changelog file per PR, using the same parsing rules as [`changelog bundle --report`](/cli/changelog/bundle.md). +: Mutually exclusive with `--prs`, `--issues`, and `--release-version`. +: For a plain newline-delimited list of fully-qualified PR URLs, use `--prs` with a file path instead of `--report`. +: When the value is an `https://` URL, only hosts allowed by the parser (such as `github.com` and `buildkite.com`) are supported, and the CLI needs network access to fetch the report. `--release-version ` : Optional: GitHub release tag to use as a source of pull requests (for example, `"v9.2.0"` or `"latest"`). : When specified, the command fetches the release from GitHub, parses PR references from the release notes, and creates one changelog file per PR — without creating a bundle. Only automated GitHub release notes (the default format or [Release Drafter](https://github.com/release-drafter/release-drafter) format) are supported at this time. : Use `docs-builder changelog gh-release` instead if you also want a bundle. : Requires `--repo` (or `bundle.repo` in `changelog.yml`). : Set to `latest` to use the most recent release. +: Mutually exclusive with `--report`, `--prs`, and `--issues`. `--repo ` -: Optional: GitHub repository name (used when `--prs`, `--issues`, or `--release-version` is specified). Falls back to `bundle.repo` in `changelog.yml` when not specified. +: Optional: GitHub repository name (used when `--prs`, `--issues`, `--report`, or `--release-version` is specified). Falls back to `bundle.repo` in `changelog.yml` when not specified. `--strip-title-prefix` -: Optional: When used with `--prs` or `--issues`, remove square brackets and text within them from the beginning of PR or issue titles, remove a colon if it follows the closing bracket, and remove a single ASCII hyphen when it's immediately after that prefix and followed by whitespace. +: Optional: When used with `--prs`, `--issues`, or `--report`, remove square brackets and text within them from the beginning of PR or issue titles, remove a colon if it follows the closing bracket, and remove a single ASCII hyphen when it's immediately after that prefix and followed by whitespace. : For example, if a PR title is `"[Discover][ESQL]: Fix filtering by multiline string fields"` it becomes `"Fix filtering by multiline string fields"`. : Likewise `"[Cases] - Enable numerical id service"` becomes `"Enable numerical id service"`. : When a derived title still begins with `-`, `*`, `+`, an en dash, or an em dash, the emitted YAML uses a quoted `title` value so it is valid and unambiguous. @@ -105,18 +113,18 @@ docs-builder changelog add [options...] [-h|--help] `--title ` : A short, user-facing title (max 80 characters) -: Required if neither `--prs` nor `--issues` is specified. +: Required if none of `--prs`, `--issues`, or `--report` is specified. : If both `--prs` and `--title` are specified, the latter value is used instead of what exists in the PR. : If the content contains any special characters such as backquotes, you must precede it with a backslash escape character (`\`). `--type ` -: Required if neither `--prs` nor `--issues` is specified. Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). +: Required if none of `--prs`, `--issues`, or `--report` is specified. Type of change (for example, `feature`, `enhancement`, `bug-fix`, or `breaking-change`). : If mappings are configured, type can be derived from the PR or issue. : The valid types are listed in [ChangelogConfiguration.cs](https://github.com/elastic/docs-builder/blob/main/src/services/Elastic.Documentation.Services/Changelog/ChangelogConfiguration.cs). `--use-pr-number` : Optional: Use PR numbers for filenames instead of the configured `filename` strategy. -: Requires `--prs` or `--issues`. +: Requires `--prs`, `--issues`, or `--report`. : Mutually exclusive with `--use-issue-number`. : Refer to [](#filenames). @@ -153,7 +161,7 @@ docs-builder changelog add \ ``` :::{important} -`--use-pr-number` and `--use-issue-number` are mutually exclusive; specify only one. Each requires `--prs` or `--issues`. The numbers are extracted from the URLs or identifiers you provide or from linked references in the issue or PR body when extraction is enabled. +`--use-pr-number` and `--use-issue-number` are mutually exclusive; specify only one. `--use-pr-number` requires `--prs`, `--issues`, or `--report`. `--use-issue-number` requires `--prs` or `--issues`. The numbers are extracted from the URLs or identifiers you provide or from linked references in the issue or PR body when extraction is enabled. **Precedence**: CLI flags (`--use-pr-number` / `--use-issue-number`) > `filename` in `changelog.yml` > default (`timestamp`). ::: @@ -181,6 +189,8 @@ The `changelog add` command resolves product values in the following order: 1. If `products.default` is defined in the changelog configuration file, those default products are used. 1. If `--repo` is specified (or `bundle.repo` is set in the changelog configuration file), the repository name is matched against known product IDs in `products.yml` and the derived value is used. +The same order applies when using `--report` (after PR URLs are resolved from the promotion report), and when using batch `--prs` with multiple pull requests. + If none of these steps yield at least one product, the command returns an error. ## Configuration checks diff --git a/src/tooling/docs-builder/Commands/ChangelogCommand.cs b/src/tooling/docs-builder/Commands/ChangelogCommand.cs index 66e200c0f3..e81b88a2f3 100644 --- a/src/tooling/docs-builder/Commands/ChangelogCommand.cs +++ b/src/tooling/docs-builder/Commands/ChangelogCommand.cs @@ -227,19 +227,20 @@ public Task Init( /// Optional: Feature flag ID /// Optional: Include in release highlights /// Optional: How the user's environment is affected - /// Optional: Issue URL(s) or number(s) (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated issues (e.g., `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (e.g., `--issues /path/to/file.txt`). If --owner and --repo are provided, issue numbers can be used instead of URLs. If specified, --title can be derived from the issue. Creates one changelog file per issue. + /// Optional: Issue URL(s) or number(s) (comma-separated), or a path to a newline-delimited file containing issue URLs or numbers. Can be specified multiple times. Each occurrence can be either comma-separated issues (e.g., `--issues "https://github.com/owner/repo/issues/123,456"`) or a file path (e.g., `--issues /path/to/file.txt`). If --owner and --repo are provided, issue numbers can be used instead of URLs. If specified, --title can be derived from the issue. Creates one changelog file per issue. Mutually exclusive with --release-version and --report. /// Optional: GitHub repository owner (used when --prs or --issues contains just numbers, or when using --release-version). Falls back to bundle.owner in changelog.yml when not specified. If that value is also absent, "elastic" is used. /// Optional: Output directory for the changelog. Falls back to bundle.directory in changelog.yml when not specified. Defaults to current directory. - /// 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: 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. Mutually exclusive with --release-version and --report. + /// Optional: URL or file path to a promotion report HTML document. Extracts GitHub pull request URLs and creates one changelog per PR (same parsing as `changelog bundle --report`). Mutually exclusive with --prs, --issues, and --release-version. /// Optional: GitHub repository name (used when --prs or --issues contains just numbers, or when using --release-version). Falls back to bundle.repo in changelog.yml when not specified. - /// Optional: When used with --prs, remove square brackets and text within them from the beginning of PR titles, and also remove a colon if it follows the closing bracket (e.g., "[Inference API] Title" becomes "Title", "[ES|QL]: Title" becomes "Title", "[Discover][ESQL] Title" becomes "Title") + /// Optional: When used with --prs or --report, remove square brackets and text within them from the beginning of PR titles, and also remove a colon if it follows the closing bracket (e.g., "[Inference API] Title" becomes "Title", "[ES|QL]: Title" becomes "Title", "[Discover][ESQL] Title" becomes "Title") /// Optional: Subtype for breaking changes (api, behavioral, configuration, etc.) - /// Optional: A short, user-facing title (max 80 characters). Required if neither --prs nor --issues is specified. If --prs and --title are specified, the latter value is used instead of what exists in the PR. - /// Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if neither --prs nor --issues is specified. If mappings are configured, type can be derived from the PR or issue. + /// Optional: A short, user-facing title (max 80 characters). Required if neither --prs, --issues, nor --report is specified. If --prs and --title are specified, the latter value is used instead of what exists in the PR. + /// Optional: Type of change (feature, enhancement, bug-fix, breaking-change, etc.). Required if neither --prs, --issues, nor --report is specified. If mappings are configured, type can be derived from the PR or issue. /// Optional: Omit schema reference comments from generated YAML files. Useful in CI to produce compact output. - /// Optional: Use PR numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its PR numbers. Requires --prs or --issues. Mutually exclusive with --use-issue-number. + /// Optional: Use PR numbers for filenames instead of timestamp-slug. With --prs, --report, or --issues (where PRs are resolved), each changelog filename will be derived from its PR numbers. Requires --prs, --report, or --issues. Mutually exclusive with --use-issue-number. /// Optional: Use issue numbers for filenames instead of timestamp-slug. With both --prs (which creates one changelog per specified PR) and --issues (which creates one changelog per specified issue), each changelog filename will be derived from its issues. Requires --prs or --issues. Mutually exclusive with --use-pr-number. - /// Optional: GitHub release tag to fetch PRs from (e.g., "v9.2.0" or "latest"). When specified, creates one changelog per PR in the release notes. Requires --repo (or bundle.repo in changelog.yml). Mutually exclusive with --prs and --issues. Does not create a bundle; use 'changelog gh-release' for that. + /// Optional: GitHub release tag to fetch PRs from (e.g., "v9.2.0" or "latest"). When specified, creates one changelog per PR in the release notes. Requires --repo (or bundle.repo in changelog.yml). Mutually exclusive with --prs, --issues, and --report. Does not create a bundle; use 'changelog gh-release' for that. /// Cancellation token [Command("add")] public async Task Create( @@ -258,6 +259,7 @@ public async Task Create( string? owner = null, string? output = null, string[]? prs = null, + string? report = null, string? releaseVersion = null, string? repo = null, bool stripTitlePrefix = false, @@ -271,9 +273,40 @@ public async Task Create( { await using var serviceInvoker = new ServiceInvoker(collector); - // Mutual exclusivity: --release-version cannot be combined with --prs or --issues + var hasReport = !string.IsNullOrWhiteSpace(report); + if (hasReport) + { + if (prs is { Length: > 0 }) + { + collector.EmitError(string.Empty, "--report and --prs cannot be specified together."); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + + if (issues is { Length: > 0 }) + { + collector.EmitError(string.Empty, "--report and --issues cannot be specified together."); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + + // Mutual exclusivity: --release-version cannot be combined with --prs, --issues, or --report if (releaseVersion != null) { + if (hasReport) + { + collector.EmitError(string.Empty, "--release-version and --report are mutually exclusive."); + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + if (prs is { Length: > 0 }) { collector.EmitError(string.Empty, "--release-version and --prs are mutually exclusive."); @@ -295,7 +328,7 @@ public async Task Create( // Load changelog config and apply fallbacks for all modes. // Precedence: CLI option > bundle section in changelog.yml > built-in default. - // This applies to --prs, --issues, and --release-version alike. + // This applies to --prs, --issues, --release-version, and --report alike. var bundleConfig = await new ChangelogConfigurationLoader(logFactory, configurationContext, _fileSystem) .LoadChangelogConfiguration(collector, config, ctx); var resolvedRepo = !string.IsNullOrWhiteSpace(repo) ? repo : bundleConfig?.Bundle?.Repo; @@ -342,9 +375,26 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c IGitHubPrService githubPrService = new GitHubPrService(logFactory); var service = new ChangelogCreationService(logFactory, configurationContext, githubPrService, env: SystemEnvironmentVariables.Instance); - // Parse PRs: handle both comma-separated values and file paths + // Parse PRs: promotion report (--report), or comma-separated values and file paths (--prs) string[]? parsedPrs = null; - if (prs is { Length: > 0 }) + if (hasReport) + { + var reportSource = report!.Trim(); + if (!reportSource.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && + !reportSource.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + reportSource = NormalizePath(reportSource); + + var reportParser = new PromotionReportParser(logFactory, null); + parsedPrs = await reportParser.ParseReportToPrUrlsAsync(collector, reportSource, ctx); + if (parsedPrs == null) + { + _ = collector.StartAsync(ctx); + await collector.WaitForDrain(); + await collector.StopAsync(ctx); + return 1; + } + } + else if (prs is { Length: > 0 }) { var allPrs = new List(); var validPrs = prs.Where(prValue => !string.IsNullOrWhiteSpace(prValue)); @@ -438,7 +488,7 @@ async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(c // --use-pr-number with --issues is allowed: PRs can be extracted from the issue body (Fixed by #123, etc.) if (usePrNumber && (parsedPrs == null || parsedPrs.Length == 0) && (parsedIssues == null || parsedIssues.Length == 0)) { - collector.EmitError(string.Empty, "--use-pr-number requires --prs or --issues to be specified."); + collector.EmitError(string.Empty, "--use-pr-number requires --prs, --issues, or --report to be specified."); _ = collector.StartAsync(ctx); await collector.WaitForDrain(); await collector.StopAsync(ctx); @@ -880,7 +930,7 @@ async static (s, collector, state, ctx) => await s.BundleChangelogs(collector, s /// Filter by pull request URLs (comma-separated) or a path to a newline-delimited file containing fully-qualified GitHub PR URLs. Can be specified multiple times. /// GitHub release tag to use as a filter source (for example, "v9.2.0" or "latest"). Fetches the release, parses PR references from the release notes, and removes changelogs whose PR URLs match — equivalent to passing the PR list using --prs. /// GitHub repository name, which is used when PRs or issues are specified as numbers or when --release-version is used. Falls back to bundle.repo in changelog.yml when not specified. If that value is also absent, the product ID is used. - /// Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, and --issues. + /// Optional (option-based mode only): URL or file path to a promotion report. Extracts PR URLs and uses them as the filter. Mutually exclusive with --all, --products, --prs, --release-version, and --issues. /// [Command("remove")] public async Task Remove( diff --git a/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs b/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs new file mode 100644 index 0000000000..f5f74ca3ea --- /dev/null +++ b/tests/Elastic.Changelog.Tests/Changelogs/Create/AddReportOptionTests.cs @@ -0,0 +1,111 @@ +// Licensed to Elasticsearch B.V under one or more agreements. +// 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 AwesomeAssertions; +using Elastic.Changelog.Bundling; +using Elastic.Changelog.Creation; +using Elastic.Changelog.GitHub; +using Elastic.Documentation.Configuration; +using FakeItEasy; + +namespace Elastic.Changelog.Tests.Changelogs.Create; + +/// +/// Tests promotion-report parsing feeding (same expansion +/// changelog add --report performs before creation). +/// +public class AddReportOptionTests(ITestOutputHelper output) : CreateChangelogTestBase(output) +{ + [Fact] + public async Task CreateChangelog_FromPromotionReportHtmlFile_CreatesOneYamlPerPr() + { + var html = + """ + + PR #7001 + PR #7002 + + """; + var reportFile = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "promotion.html"); + FileSystem.Directory.CreateDirectory(FileSystem.Path.GetDirectoryName(reportFile)!); + await FileSystem.File.WriteAllTextAsync(reportFile, html, TestContext.Current.CancellationToken); + + var pr1 = new GitHubPrInfo { Title = "First from report", Labels = ["type:feature"] }; + var pr2 = new GitHubPrInfo { Title = "Second from report", Labels = ["type:bug"] }; + A.CallTo(() => MockGitHubService.FetchPrInfoAsync( + A.That.Contains("7001"), + null, + null, + A._)) + .Returns(pr1); + A.CallTo(() => MockGitHubService.FetchPrInfoAsync( + A.That.Contains("7002"), + null, + null, + A._)) + .Returns(pr2); + + // language=yaml + var configContent = + """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug" + breaking-change: + lifecycles: + - preview + - beta + - ga + """; + var configPath = await CreateConfigDirectory(configContent); + + var parser = new PromotionReportParser(LoggerFactory, FileSystem); + var prUrls = await parser.ParseReportToPrUrlsAsync(Collector, reportFile, TestContext.Current.CancellationToken); + prUrls.Should().NotBeNull(); + prUrls!.Should().HaveCount(2); + + var service = CreateService(); + var input = new CreateChangelogArguments + { + Prs = prUrls, + Products = [new ProductArgument { Product = "elasticsearch", Target = "9.2.0", Lifecycle = "ga" }], + Config = configPath, + Output = CreateOutputDirectory(), + UsePrNumber = true + }; + + var result = await service.CreateChangelog(Collector, input, TestContext.Current.CancellationToken); + + result.Should().BeTrue(); + Collector.Errors.Should().Be(0); + + var outputDir = input.Output!; + var files = FileSystem.Directory.GetFiles(outputDir, "*.yaml"); + files.Should().HaveCount(2); + Array.Sort(files, StringComparer.Ordinal); + Path.GetFileName(files[0]).Should().Be("7001.yaml"); + Path.GetFileName(files[1]).Should().Be("7002.yaml"); + + var yaml1 = await FileSystem.File.ReadAllTextAsync(files[0], TestContext.Current.CancellationToken); + yaml1.Should().Contain("title: First from report"); + yaml1.Should().Contain("https://github.com/elastic/elasticsearch/pull/7001"); + + var yaml2 = await FileSystem.File.ReadAllTextAsync(files[1], TestContext.Current.CancellationToken); + yaml2.Should().Contain("title: Second from report"); + yaml2.Should().Contain("https://github.com/elastic/elasticsearch/pull/7002"); + } + + [Fact] + public async Task PromotionReportParser_ReportFileMissing_EmitsError() + { + var missing = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "nope.html"); + var parser = new PromotionReportParser(LoggerFactory, FileSystem); + + var prUrls = await parser.ParseReportToPrUrlsAsync(Collector, missing, TestContext.Current.CancellationToken); + + prUrls.Should().BeNull(); + Collector.Errors.Should().BeGreaterThan(0); + } +}