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
9 changes: 8 additions & 1 deletion docs/cli/release/changelog-add.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,15 @@ docs-builder changelog add [options...] [-h|--help]
: Creates one changelog file per PR.
: If there are `block ... create` definitions 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.

`--release-version <string?>`
: 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.
: Use `docs-builder changelog gh-release` instead if you also want a bundle.
: Requires `--repo`. Mutually exclusive with `--prs` and `--issues`.
: Set to `latest` to use the most recent release.

`--repo <string?>`
: Optional: GitHub repository name (used when `--prs` or `--issues` contains just numbers).
: Optional: GitHub repository name (used when `--prs`, `--issues`, or `--release-version` is specified).

`--strip-title-prefix`
: 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.
Expand Down
22 changes: 21 additions & 1 deletion docs/contribute/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ For up-to-date command usage information, use the `-h` option or refer to [](/cl

### Authorization

If you use the `--prs` or `--issues` options, the `docs-builder changelog add` command interacts with GitHub services.
If you use the `--prs`, `--issues`, or `--release-version` options, the `docs-builder changelog add` command interacts with GitHub services.
Log into GitHub or set the `GITHUB_TOKEN` (or `GH_TOKEN` ) environment variable with a sufficient personal access token (PAT).
Otherwise, there will be fetch failures when you access private repositories and you might also encounter GitHub rate limiting errors.

Expand Down Expand Up @@ -460,6 +460,26 @@ For example, a tag of `v9.2.0` on `elasticsearch` creates changelogs with `produ
This command requires a `GITHUB_TOKEN` or `GH_TOKEN` environment variable (or an active `gh` login) to fetch release details from the GitHub API. Refer to [Authorization](#authorization) for details.
:::

#### Create changelogs from a release [changelog-add-release-version]

You can use the `--release-version` option to create changelog files for all pull requests in a GitHub release, without creating a bundle.
This is useful when you want to add release-based changelogs into an existing workflow without committing to the full `changelog gh-release` one-shot approach.

```sh
docs-builder changelog add \
--release-version v9.2.0 \
--repo elasticsearch \
--output ./docs/changelog \
--config ./docs/changelog.yml
```

This creates one changelog file per PR found in the `v9.2.0` release notes of `elastic/elasticsearch`.
Unlike `changelog gh-release`, no bundle file is created.

:::{note}
`--release-version` requires `--repo` and is mutually exclusive with `--prs` and `--issues`.
:::

## Create bundles [changelog-bundle]

You can use the `docs-builder changelog bundle` command to create a YAML file that lists multiple changelogs.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ public record CreateChangelogsFromReleaseArguments
/// Whether to warn when Release Drafter type doesn't match label-derived type (defaults to true)
/// </summary>
public bool WarnOnTypeMismatch { get; init; } = true;

/// <summary>
/// Whether to create a bundle file after creating individual changelog files. Defaults to true.
/// Set to false when called from 'changelog add --release-version' to skip bundle creation.
/// </summary>
public bool CreateBundle { get; init; } = true;
}

/// <summary>
Expand Down Expand Up @@ -166,8 +172,8 @@ Cancel ctx

_logger.LogInformation("Created {Count} changelog files from release {Tag}", successCount, release.TagName);

// 8. Create bundle file if changelogs were created
if (createdFiles.Count > 0)
// 8. Optionally create bundle file if changelogs were created
if (input.CreateBundle && createdFiles.Count > 0)
{
var bundlePath = await CreateBundleFile(outputDir, createdFiles, productInfo, ctx);
_logger.LogInformation("Created bundle file: {BundlePath}", bundlePath);
Expand Down
44 changes: 44 additions & 0 deletions src/tooling/docs-builder/Commands/ChangelogCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ public async Task<int> Create(
string? owner = null,
string? output = null,
string[]? prs = null,
string? releaseVersion = null,
string? repo = null,
bool stripTitlePrefix = false,
string? subtype = null,
Expand All @@ -253,6 +254,49 @@ public async Task<int> Create(
{
await using var serviceInvoker = new ServiceInvoker(collector);

// --release-version mode: delegate entirely to GitHubReleaseChangelogService without creating a bundle
if (releaseVersion != null)
{
if (prs is { Length: > 0 })
{
collector.EmitError(string.Empty, "--release-version and --prs are mutually exclusive.");
return 1;
}

if (issues is { Length: > 0 })
{
collector.EmitError(string.Empty, "--release-version and --issues are mutually exclusive.");
return 1;
}

if (string.IsNullOrWhiteSpace(repo))
{
collector.EmitError(string.Empty, "--release-version requires --repo to be specified.");
return 1;
}

var repoArg = string.IsNullOrWhiteSpace(owner) ? repo : $"{owner}/{repo}";
IGitHubReleaseService releaseService = new GitHubReleaseService(logFactory);
IGitHubPrService prService = new GitHubPrService(logFactory);
var releaseChangelogService = new GitHubReleaseChangelogService(logFactory, configurationContext, releaseService, prService);

var releaseInput = new CreateChangelogsFromReleaseArguments
{
Repository = repoArg,
Version = releaseVersion,
Config = config,
Output = output,
StripTitlePrefix = stripTitlePrefix,
CreateBundle = false
};

serviceInvoker.AddCommand(releaseChangelogService, releaseInput,
async static (s, collector, state, ctx) => await s.CreateChangelogsFromRelease(collector, state, ctx)
);

return await serviceInvoker.InvokeAsync(ctx);
}

IGitHubPrService githubPrService = new GitHubPrService(logFactory);
var service = new ChangelogCreationService(logFactory, configurationContext, githubPrService);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// 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 Elastic.Changelog.GitHub;
using Elastic.Changelog.GithubRelease;
using FakeItEasy;
using FluentAssertions;
using Xunit;

namespace Elastic.Changelog.Tests.Changelogs.Create;

/// <summary>
/// Tests for 'changelog add --release-version' behaviour, implemented via
/// <see cref="GitHubReleaseChangelogService"/> with <see cref="CreateChangelogsFromReleaseArguments.CreateBundle"/> = false.
/// </summary>
public class ReleaseVersionTests(ITestOutputHelper output) : ChangelogTestBase(output)
{
private readonly IGitHubReleaseService _mockReleaseService = A.Fake<IGitHubReleaseService>();
private readonly IGitHubPrService _mockPrService = A.Fake<IGitHubPrService>();

private GitHubReleaseChangelogService CreateService() =>
new(LoggerFactory, ConfigurationContext, _mockReleaseService, _mockPrService, FileSystem);

private string CreateOutputDirectory() =>
FileSystem.Path.Combine(FileSystem.Path.GetTempPath(), Guid.NewGuid().ToString());

// -----------------------------------------------------------------------
// Validation: no PR refs in release notes
// -----------------------------------------------------------------------

[Fact]
public async Task ReleaseVersion_WithNoMatchingPrs_EmitsWarningAndSucceeds()
{
// Arrange
A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A<Cancel>._))
.Returns(new GitHubReleaseInfo
{
TagName = "v9.2.0",
Name = "9.2.0",
Body = "No pull request references in these release notes."
});

var service = CreateService();
var input = new CreateChangelogsFromReleaseArguments
{
Repository = "elastic/elasticsearch",
Version = "v9.2.0",
Output = CreateOutputDirectory(),
CreateBundle = false
};

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

// Assert
result.Should().BeTrue();
Collector.Diagnostics.Should().Contain(d =>
d.Message.Contains("No PR references found") && d.Severity == Documentation.Diagnostics.Severity.Warning);
}

// -----------------------------------------------------------------------
// CreateBundle = false: no bundle file is written
// -----------------------------------------------------------------------

[Fact]
public async Task ReleaseVersion_WithValidRelease_CreatesChangelogFiles_AndNoBundleFile()
{
// Arrange – GitHub Default format body with two PR references
// Parser expects: "* Title by @author in #NNN"
var releaseBody =
"""
## What's Changed

* Fix query parsing edge case by @contributor1 in #12345
* Resolve memory leak in shard recovery by @contributor2 in #12346

**Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0
""";

A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A<Cancel>._))
.Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody });

A.CallTo(() => _mockPrService.FetchPrInfoAsync(A<string>._, A<string?>._, A<string?>._, A<Cancel>._))
.Returns(new GitHubPrInfo { Title = "PR title", Labels = [] });

var outputDir = CreateOutputDirectory();
FileSystem.Directory.CreateDirectory(outputDir);

var service = CreateService();
var input = new CreateChangelogsFromReleaseArguments
{
Repository = "elastic/elasticsearch",
Version = "v9.2.0",
Output = outputDir,
CreateBundle = false
};

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

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

var yamlFiles = FileSystem.Directory.GetFiles(outputDir, "*.yaml");
yamlFiles.Should().HaveCount(2, "one changelog file per PR reference");

// No bundle file in the output directory or bundles subdirectory
var bundlesDir = FileSystem.Path.Combine(outputDir, "bundles");
FileSystem.Directory.Exists(bundlesDir).Should().BeFalse("CreateBundle = false must not create a bundles directory");
}

// -----------------------------------------------------------------------
// CreateBundle = true (default): bundle file is written
// -----------------------------------------------------------------------

[Fact]
public async Task GhRelease_WithValidRelease_CreatesBundleFile()
{
// Arrange – GitHub Default format body with one PR reference
var releaseBody =
"""
## What's Changed

* Add new aggregation API by @contributor1 in #12345

**Full Changelog**: https://github.com/elastic/elasticsearch/compare/v9.1.0...v9.2.0
""";

A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "v9.2.0", A<Cancel>._))
.Returns(new GitHubReleaseInfo { TagName = "v9.2.0", Name = "9.2.0", Body = releaseBody });

A.CallTo(() => _mockPrService.FetchPrInfoAsync(A<string>._, A<string?>._, A<string?>._, A<Cancel>._))
.Returns(new GitHubPrInfo { Title = "Add aggregation API", Labels = [] });

var outputDir = CreateOutputDirectory();
FileSystem.Directory.CreateDirectory(outputDir);

var service = CreateService();
var input = new CreateChangelogsFromReleaseArguments
{
Repository = "elastic/elasticsearch",
Version = "v9.2.0",
Output = outputDir,
CreateBundle = true
};

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

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

var bundlesDir = FileSystem.Path.Combine(outputDir, "bundles");
FileSystem.Directory.Exists(bundlesDir).Should().BeTrue();
var bundleFiles = FileSystem.Directory.GetFiles(bundlesDir, "*.yml");
bundleFiles.Should().HaveCount(1, "a bundle file should be created when CreateBundle = true");
}

// -----------------------------------------------------------------------
// Latest tag: FetchReleaseAsync is called with "latest"
// -----------------------------------------------------------------------

[Fact]
public async Task ReleaseVersion_Latest_CallsFetchWithLatestTag()
{
// Arrange
A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A<Cancel>._))
.Returns(new GitHubReleaseInfo
{
TagName = "v9.2.0",
Name = "9.2.0",
Body = "No PR references."
});

var service = CreateService();
var input = new CreateChangelogsFromReleaseArguments
{
Repository = "elastic/elasticsearch",
Version = "latest",
Output = CreateOutputDirectory(),
CreateBundle = false
};

// Act
_ = await service.CreateChangelogsFromRelease(Collector, input, TestContext.Current.CancellationToken);

// Assert
A.CallTo(() => _mockReleaseService.FetchReleaseAsync("elastic", "elasticsearch", "latest", A<Cancel>._))
.MustHaveHappenedOnceExactly();
}

// -----------------------------------------------------------------------
// Release fetch failure
// -----------------------------------------------------------------------

[Fact]
public async Task ReleaseVersion_FetchFailure_ReturnsError()
{
// Arrange
A.CallTo(() => _mockReleaseService.FetchReleaseAsync(A<string>._, A<string>._, A<string?>._, A<Cancel>._))
.Returns((GitHubReleaseInfo?)null);

var service = CreateService();
var input = new CreateChangelogsFromReleaseArguments
{
Repository = "elastic/elasticsearch",
Version = "v9.2.0",
Output = CreateOutputDirectory(),
CreateBundle = false
};

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

// Assert
result.Should().BeFalse();
Collector.Errors.Should().BeGreaterThan(0);
}

// -----------------------------------------------------------------------
// Unknown repository: no product found
// -----------------------------------------------------------------------

[Fact]
public async Task ReleaseVersion_UnknownRepo_ReturnsError()
{
// Arrange – "unknown-repo" is not registered in ConfigurationContext.ProductsConfiguration
var service = CreateService();
var input = new CreateChangelogsFromReleaseArguments
{
Repository = "elastic/unknown-repo",
Version = "v9.2.0",
Output = CreateOutputDirectory(),
CreateBundle = false
};

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

// Assert
result.Should().BeFalse();
Collector.Errors.Should().BeGreaterThan(0);
Collector.Diagnostics.Should().Contain(d => d.Message.Contains("unknown-repo"));
}
}
Loading