diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index cc92d1dc5..bfc807a9d 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -75,10 +75,11 @@ public async Task 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 @@ -142,7 +143,8 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr PrEvaluationResult.NoLabel, title, resolvedDescription: description, labelTable: BuildLabelTable(config.LabelToType), - productLabelTable: productLabelTable + productLabelTable: productLabelTable, + skipLabels: skipLabels ); } @@ -169,7 +171,8 @@ private async Task 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 @@ -196,10 +199,44 @@ private async Task 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; } + /// + /// 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. + /// + internal static string? CollectExcludeLabels(CreateRules? createRules) + { + if (createRules == null) + return null; + + var labels = new HashSet(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; + } + /// /// Finds an existing changelog file for the given PR in the changelog directory. /// Returns the filename (not the full path) if found, or null. diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs index 38f626af0..822eedbdf 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs @@ -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; @@ -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 + { + ["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 + { + ["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 + { + ["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.That.Contains(">non-issue"))).MustHaveHappened(); + A.CallTo(() => _mockCore.SetOutputAsync("skip-labels", A.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.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._)).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._)).MustNotHaveHappened(); + } }