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: 4 additions & 2 deletions docs/cli/changelog/cmd-bundle.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,9 @@ https://github.com/elastic/elasticsearch/pull/136886
https://github.com/elastic/elasticsearch/pull/137126
```

By default all changelogs that match PRs in the list are included in the bundle.
A changelog matches a listed PR when the file name's leading dash-separated numeric segments include that PR number (for example `12345.yaml`) or when the `prs:` field contains it. That is the same rule git-range matching uses. Local `--prs` therefore also matches leftover hyphenated or timestamp-prefix names whose leading digits equal the requested PR. Use [`--files`](#changelog-bundle-files) when you need to select entries by path. CDN `--prs` is separate: it probes `{n}.yaml` by object key and does not scan the directory.

By default all matching changelogs are included in the bundle.
To apply additional filtering by the changelog type, areas, or products, add [rules.bundle](/data/release-notes/configure-ref.md#rules-bundle) configuration settings.

If you have changelog files that reference those pull requests, the command creates a file like this:
Expand Down Expand Up @@ -425,7 +427,7 @@ In profile mode, pass the same path list as a positional argument:
docs-builder changelog bundle serverless-release 2026-07-07 ./docs/temp/changelog_files.txt
```

`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN pool entries by file name and do not need to exist locally — useful for private repositories whose entries exist only in S3 and whose public copies have PR/issue references scrubbed, so PR-based filters cannot match. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection.
`--files` / path-list selection follows the standard entry-sourcing rules. When entries are sourced from the CDN (the default when `bundle.repo` resolves), the listed paths are matched to CDN pool entries by file name and do not need to exist locally. Use this when you already know the object names — for example leftover timestamp-slug files that CDN [`--prs`](#changelog-bundle-pr) cannot find (CDN probes `{n}.yaml` only) or that you want to select by path rather than by leading filename digits or YAML `prs:` / `issues:`. With local sourcing (`--force-local`, `--directory`, or `bundle.use_local_changelogs`), the listed files are read from disk and must exist. In either mode, a listed entry that cannot be found fails the run, and `rules.bundle` still applies after selection.

### Force local entry sourcing [changelog-bundle-force-local]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ HashSet<string> matchedIssues
return null;
}

if (!MatchesFilter(yamlDto, criteria, matchedPrs, matchedIssues))
if (!MatchesFilter(yamlDto, fileName, criteria, matchedPrs, matchedIssues))
return null;

// Add to seen set
Expand All @@ -228,6 +228,7 @@ HashSet<string> matchedIssues

private static bool MatchesFilter(
ChangelogEntryDto data,
string fileName,
ChangelogFilterCriteria criteria,
HashSet<string> matchedPrs,
HashSet<string> matchedIssues
Expand All @@ -240,7 +241,7 @@ HashSet<string> matchedIssues
return MatchesProductFilter(data, criteria.ProductFilters);

if (criteria.PrsToMatch.Count > 0)
return MatchesPrFilter(data, criteria, matchedPrs);
return MatchesPrFilter(data, fileName, criteria, matchedPrs);

if (criteria.IssuesToMatch.Count > 0)
return MatchesIssueFilter(data, criteria, matchedIssues);
Expand Down Expand Up @@ -289,8 +290,24 @@ private static bool MatchesProductFilter(ChangelogEntryDto data, IReadOnlyList<P
return false;
}

private static bool MatchesPrFilter(ChangelogEntryDto data, ChangelogFilterCriteria criteria, HashSet<string> matchedPrs)
private static bool MatchesPrFilter(
ChangelogEntryDto data,
string fileName,
ChangelogFilterCriteria criteria,
HashSet<string> matchedPrs
)
{
var fileNumbers = ChangelogPrIdentity.ParseLeadingPrNumbers(fileName);
Comment thread
lcawl marked this conversation as resolved.
foreach (var pr in criteria.PrsToMatch)
{
var normalizedPrToMatch = ChangelogBundlingService.NormalizePrForComparison(pr, criteria.DefaultOwner, criteria.DefaultRepo);
if (ChangelogPrIdentity.TryParseNumberFromNormalized(normalizedPrToMatch, out var prNumber) && fileNumbers.Contains(prNumber))
{
_ = matchedPrs.Add(pr);
return true;
}
}

var prs = data.Prs ?? (data.Pr != null ? [data.Pr] : null);
if (prs is not { Count: > 0 })
return false;
Expand Down
52 changes: 52 additions & 0 deletions src/services/Elastic.Changelog/Bundling/ChangelogPrIdentity.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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

namespace Elastic.Changelog.Bundling;

/// <summary>
/// Shared identity for matching a changelog entry to a pull request: leading numeric filename
/// segments or normalized YAML <c>prs:</c> references.
/// </summary>
internal static class ChangelogPrIdentity
{
/// <summary>
/// Parses PR numbers from the leading dash-separated numeric segments of an entry file name
/// (for example <c>123.yaml</c>). Leftover hyphenated names may still yield leading digits.
/// Current multi-PR authoring writes one <c>{n}.yaml</c> per PR; after upload the scrubber
/// adds <c>{extra}.yaml</c> markers with <c>link: {canonical}</c> rather than combined
/// filenames. File names survive scrubbing, so this match works when <c>prs:</c> is absent
/// from the YAML the matcher reads (including local files where an author removed <c>prs:</c>).
/// </summary>
public static IReadOnlyList<int> ParseLeadingPrNumbers(string fileName)
{
var stem = fileName;
var extensionIndex = stem.LastIndexOf('.');
if (extensionIndex > 0)
stem = stem[..extensionIndex];

var numbers = new List<int>();
foreach (var segment in stem.Split('-'))
{
if (segment.Length > 0 && segment.All(char.IsAsciiDigit) && int.TryParse(segment, out var number))
numbers.Add(number);
else
break;
}

return numbers;
}

/// <summary>
/// Extracts the PR number from a value already normalized by
/// <see cref="ChangelogBundlingService.NormalizePrForComparison"/> (<c>owner/repo#n</c>).
/// </summary>
public static bool TryParseNumberFromNormalized(string normalized, out int number)
{
number = 0;
var hash = normalized.LastIndexOf('#');
if (hash < 0 || hash == normalized.Length - 1)
return false;
return int.TryParse(normalized[(hash + 1)..], out number);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ IReadOnlyList<string> Prs

internal static ChangelogPoolCandidate ParseCandidate(string fileName, string content)
{
var numbers = ParseLeadingPrNumbers(fileName);
var numbers = ChangelogPrIdentity.ParseLeadingPrNumbers(fileName);
try
{
var checksum = ChangelogBundlingService.ComputeSha1(content);
Expand All @@ -244,31 +244,6 @@ internal static ChangelogPoolCandidate ParseCandidate(string fileName, string co
}
}

/// <summary>
/// Parses PR numbers from the leading dash-separated numeric segments of an entry file name,
/// covering the PR-number naming schemes (<c>123.yaml</c>, <c>123-456.yaml</c>,
/// <c>123-bug-fix-slug.yaml</c>). File names survive scrubbing, so this match works for
/// private pools whose <c>prs</c> references were removed from the public copies.
/// </summary>
internal static IReadOnlyList<int> ParseLeadingPrNumbers(string fileName)
{
var stem = fileName;
var extensionIndex = stem.LastIndexOf('.');
if (extensionIndex > 0)
stem = stem[..extensionIndex];

var numbers = new List<int>();
foreach (var segment in stem.Split('-'))
{
if (segment.Length > 0 && segment.All(char.IsAsciiDigit) && int.TryParse(segment, out var number))
numbers.Add(number);
else
break;
}

return numbers;
}

/// <summary>
/// Whether a pool entry belongs to a PR: by file-name-derived PR numbers (file names survive
/// scrubbing, so this works for private pools whose <c>prs</c> references were removed from the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,68 @@ public async Task BundleChangelogs_WithPrsFilter_FiltersCorrectly()
bundleContent.Should().NotContain("name: 1755268150-third-pr.yaml");
}

[Fact]
public async Task BundleChangelogs_WithPrsFilter_MatchesFilenameDigitsWhenYamlPrsEmpty()
{
var changelog =
"""
title: Filename identity
type: feature
products:
- product: elasticsearch
target: 9.2.0
""";

var file = FileSystem.Path.Join(_changelogDir, "12345.yaml");
await FileSystem.File.WriteAllTextAsync(file, changelog, TestContext.Current.CancellationToken);

var input = new BundleChangelogsArguments
{
Directory = _changelogDir,
Prs = ["https://github.com/elastic/elasticsearch/pull/12345"],
Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml")
};

var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);

result.Should().BeTrue();
Collector.Errors.Should().Be(0);
var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken);
bundleContent.Should().Contain("name: 12345.yaml");
}

[Fact]
public async Task BundleChangelogs_WithPrsFilter_MatchesTimestampFileViaYamlPrs()
{
var changelog =
"""
title: Timestamp identity
type: feature
products:
- product: elasticsearch
target: 9.2.0
prs:
- https://github.com/elastic/elasticsearch/pull/12345
""";

var file = FileSystem.Path.Join(_changelogDir, "1735-foo.yaml");
await FileSystem.File.WriteAllTextAsync(file, changelog, TestContext.Current.CancellationToken);

var input = new BundleChangelogsArguments
{
Directory = _changelogDir,
Prs = ["https://github.com/elastic/elasticsearch/pull/12345"],
Output = FileSystem.Path.Join(Paths.WorkingDirectoryRoot.FullName, Guid.NewGuid().ToString(), "bundle.yaml")
};

var result = await Service.BundleChangelogs(Collector, input, TestContext.Current.CancellationToken);

result.Should().BeTrue();
Collector.Errors.Should().Be(0);
var bundleContent = await FileSystem.File.ReadAllTextAsync(input.Output, TestContext.Current.CancellationToken);
bundleContent.Should().Contain("name: 1735-foo.yaml");
}

[Fact]
public async Task BundleChangelogs_WithIssuesFilter_FiltersCorrectly()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -411,7 +411,7 @@ public void GitRangeReport_ToMarkdown_ListsPrSourcesAndOrphanCommits()
[InlineData("sturdier-snapshots.yaml", new int[0])]
[InlineData("1755000000-my-title.yaml", new[] { 1755000000 })]
public void ParseLeadingPrNumbers_CoversNamingSchemes(string fileName, int[] expected) =>
GitRangeEntryResolver.ParseLeadingPrNumbers(fileName).Should().Equal(expected);
ChangelogPrIdentity.ParseLeadingPrNumbers(fileName).Should().Equal(expected);

private static HttpResponseMessage Json(string body) =>
new(HttpStatusCode.OK) { Content = new StringContent(body, System.Text.Encoding.UTF8, "application/json") };
Expand Down
Loading