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
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,11 @@ public async Task<bool> EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr
}

// Label-based skip check
var skipLabels = CollectExcludeLabels(config.Rules?.Create);
if (PrInfoProcessor.AreAllProductsBlocked(input.PrLabels, config.Rules?.Create))
{
_logger.LogInformation("Skipping: all products blocked by label rules");
return await SetOutputs(PrEvaluationResult.Skipped);
return await SetOutputs(PrEvaluationResult.Skipped, skipLabels: skipLabels);
}

// Resolve title: prefer release notes from PR body, fall back to PR title
Expand Down Expand Up @@ -142,7 +143,8 @@ public async Task<bool> EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr
PrEvaluationResult.NoLabel, title,
resolvedDescription: description,
labelTable: BuildLabelTable(config.LabelToType),
productLabelTable: productLabelTable
productLabelTable: productLabelTable,
skipLabels: skipLabels
);
}

Expand All @@ -169,7 +171,8 @@ private async Task<bool> SetOutputs(
string? labelTable = null,
string? productLabelTable = null,
string? changelogDir = null,
string? existingFilename = null)
string? existingFilename = null,
string? skipLabels = null)
{
var statusString = status == PrEvaluationResult.Success
? ProceedStatus
Expand All @@ -196,10 +199,44 @@ private async Task<bool> SetOutputs(
await coreService.SetOutputAsync("changelog-dir", changelogDir);
if (existingFilename != null)
await coreService.SetOutputAsync("existing-changelog-filename", existingFilename);
if (skipLabels != null)
await coreService.SetOutputAsync("skip-labels", skipLabels);

return true;
}

/// <summary>
/// Collects all exclude-mode labels from global and per-product create rules.
/// Returns a comma-separated string of unique labels, or null when none are configured.
/// </summary>
internal static string? CollectExcludeLabels(CreateRules? createRules)
{
if (createRules == null)
return null;

var labels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

if (createRules is { Mode: FieldMode.Exclude, Labels.Count: > 0 })
{
foreach (var label in createRules.Labels)
_ = labels.Add(label);
}

if (createRules.ByProduct is { Count: > 0 })
{
foreach (var (_, productRules) in createRules.ByProduct)
{
if (productRules is { Mode: FieldMode.Exclude, Labels.Count: > 0 })
{
foreach (var label in productRules.Labels)
_ = labels.Add(label);
}
}
}

return labels.Count > 0 ? string.Join(",", labels) : null;
}

/// <summary>
/// Finds an existing changelog file for the given PR in the changelog directory.
/// Returns the filename (not the full path) if found, or null.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
using Elastic.Changelog.GitHub;
using Elastic.Changelog.Tests.Changelogs;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Changelog;
using Elastic.Documentation.ReleaseNotes;
using FakeItEasy;

namespace Elastic.Changelog.Tests.Evaluation;
Expand Down Expand Up @@ -638,4 +640,174 @@ New aggregation pipeline support
result.Should().BeTrue();
VerifyOutputSet("title", "New aggregation pipeline support");
}

// --- CollectExcludeLabels unit tests ---

[Fact]
public void CollectExcludeLabels_Null_ReturnsNull() =>
ChangelogPrEvaluationService.CollectExcludeLabels(null).Should().BeNull();

[Fact]
public void CollectExcludeLabels_NoLabels_ReturnsNull() =>
ChangelogPrEvaluationService.CollectExcludeLabels(new CreateRules()).Should().BeNull();

[Fact]
public void CollectExcludeLabels_GlobalExcludeLabels_ReturnsCommaSeparated()
{
var rules = new CreateRules
{
Mode = FieldMode.Exclude,
Labels = [">non-issue", ">test"]
};

var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);

result.Should().NotBeNull();
result.Split(',').Should().BeEquivalentTo([">non-issue", ">test"]);
}

[Fact]
public void CollectExcludeLabels_IncludeMode_ReturnsNull()
{
var rules = new CreateRules
{
Mode = FieldMode.Include,
Labels = [">non-issue"]
};

ChangelogPrEvaluationService.CollectExcludeLabels(rules).Should().BeNull();
}

[Fact]
public void CollectExcludeLabels_PerProductExcludeOnly_ReturnsLabels()
{
var rules = new CreateRules
{
ByProduct = new Dictionary<string, CreateRules>
{
["cloud-hosted"] = new() { Mode = FieldMode.Exclude, Labels = [">skip-ech"] },
["cloud-serverless"] = new() { Mode = FieldMode.Exclude, Labels = [">skip-ess"] }
}
};

var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);

result.Should().NotBeNull();
result.Split(',').Should().BeEquivalentTo([">skip-ech", ">skip-ess"]);
}

[Fact]
public void CollectExcludeLabels_GlobalAndPerProduct_MergesUniqueLabels()
{
var rules = new CreateRules
{
Mode = FieldMode.Exclude,
Labels = [">skip-all", ">shared"],
ByProduct = new Dictionary<string, CreateRules>
{
["cloud-hosted"] = new() { Mode = FieldMode.Exclude, Labels = [">shared", ">skip-ech"] }
}
};

var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);

result.Should().NotBeNull();
result.Split(',').Should().BeEquivalentTo([">skip-all", ">shared", ">skip-ech"]);
}

[Fact]
public void CollectExcludeLabels_PerProductIncludeMode_IgnoresIncludeProducts()
{
var rules = new CreateRules
{
Mode = FieldMode.Exclude,
Labels = [">global"],
ByProduct = new Dictionary<string, CreateRules>
{
["cloud-hosted"] = new() { Mode = FieldMode.Include, Labels = [">include-only"] }
}
};

var result = ChangelogPrEvaluationService.CollectExcludeLabels(rules);

result.Should().NotBeNull();
result.Split(',').Should().BeEquivalentTo([">global"]);
}

// --- skip-labels output integration tests ---

private const string ConfigWithExcludeRules = """
pivot:
types:
feature: "type:feature"
bug-fix: "type:bug"
breaking-change: "type:breaking"
enhancement:
deprecation:
docs:
known-issue:
other:
regression:
security:
rules:
create:
exclude: ">non-issue, >test"
""";

[Fact]
public async Task EvaluatePr_WithExcludeRules_AllBlocked_OutputsSkipLabels()
{
await WriteMinimalConfig(content: ConfigWithExcludeRules);
var service = CreateService();
var args = DefaultArgs(prLabels: [">non-issue"]);

var result = await service.EvaluatePr(Collector, args, CancellationToken.None);

result.Should().BeTrue();
VerifyOutputSet("status", "skipped");
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>.That.Contains(">non-issue"))).MustHaveHappened();
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>.That.Contains(">test"))).MustHaveHappened();
}

[Fact]
public async Task EvaluatePr_WithExcludeRules_NoLabel_OutputsSkipLabels()
{
await WriteMinimalConfig(content: ConfigWithExcludeRules);
var service = CreateService();
var args = DefaultArgs(prLabels: ["unrelated-label"]);

var result = await service.EvaluatePr(Collector, args, CancellationToken.None);

result.Should().BeTrue();
VerifyOutputSet("status", "no-label");
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>.That.Contains(">non-issue"))).MustHaveHappened();
}

[Fact]
public async Task EvaluatePr_WithoutExcludeRules_DoesNotOutputSkipLabels()
{
await WriteMinimalConfig();
var service = CreateService();
var args = DefaultArgs();

var result = await service.EvaluatePr(Collector, args, CancellationToken.None);

result.Should().BeTrue();
VerifyOutputSet("status", "proceed");
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>._)).MustNotHaveHappened();
}

[Fact]
public async Task EvaluatePr_HappyPath_WithExcludeRules_DoesNotOutputSkipLabels()
{
await WriteMinimalConfig(content: ConfigWithExcludeRules);
var service = CreateService();
var args = DefaultArgs(prLabels: ["type:feature"]);

var result = await service.EvaluatePr(Collector, args, CancellationToken.None);

result.Should().BeTrue();
VerifyOutputSet("status", "proceed");
A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A<string>._)).MustNotHaveHappened();
}
}
Loading