diff --git a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs index a6edeb836..d4074f79a 100644 --- a/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs +++ b/src/services/Elastic.Changelog/Evaluation/ChangelogPrEvaluationService.cs @@ -114,16 +114,31 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr // Resolve products from labels string? resolvedProducts = null; string? productLabelTable = null; - if (config.LabelToProducts is { Count: > 0 }) + if (config.LabelToProducts is { Count: > 0 } labelToProducts) { - var products = PrInfoProcessor.MapLabelsToProducts(input.PrLabels, config.LabelToProducts); + var products = PrInfoProcessor.MapLabelsToProducts(input.PrLabels, labelToProducts); if (products.Count > 0) { resolvedProducts = ProductArgument.FormatProductSpecs(products); _logger.LogInformation("Mapped PR labels to products: {Products}", resolvedProducts); } else - productLabelTable = BuildProductLabelTable(config.LabelToProducts); + { + // When only one distinct product is configured, assigning it implicitly is + // unambiguous — no point requiring contributors to add a redundant label. + var distinctSpecs = labelToProducts.Values + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (distinctSpecs.Count == 1) + { + resolvedProducts = ProductArgument.FormatProductSpecs( + ProductArgument.ParseProductSpecs(distinctSpecs[0]) + ); + _logger.LogInformation("Single product configured; assigning implicitly: {Products}", resolvedProducts); + } + else + productLabelTable = BuildProductLabelTable(labelToProducts); + } } if (resolvedType == null) @@ -138,6 +153,22 @@ public async Task EvaluatePr(IDiagnosticsCollector collector, EvaluatePrAr ); } + // Multiple products are configured via labels but none matched, and no defaults are + // available to fill in via inference at 'changelog add' time. Surface this as a + // missing-label failure so the contributor sees an actionable hint instead of a hard + // error later in the workflow. + if (productLabelTable != null + && (config.ProductsConfiguration?.Default is null or { Count: 0 })) + { + _logger.LogInformation("Multiple products configured but no matching product label on PR; no default products configured"); + return await SetOutputs( + PrEvaluationResult.NoLabel, title, + resolvedDescription: description, + productLabelTable: productLabelTable, + skipLabels: skipLabels + ); + } + _logger.LogInformation("PR evaluation complete: title={Title}, type={Type}, products={Products}, existingFile={File}", title, resolvedType, resolvedProducts, existingFilename); return await SetOutputs( PrEvaluationResult.Success, title, resolvedType, diff --git a/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs b/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs index 3460ba4d6..ef14f6749 100644 --- a/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs +++ b/src/services/Elastic.Changelog/Evaluation/PrEvaluationResult.cs @@ -15,7 +15,7 @@ public enum PrEvaluationResult [Display(Name = "success")] Success, - /// No label matching a changelog type was found on the PR. + /// A required label (type or product) is missing from the PR. [Display(Name = "no-label")] NoLabel, diff --git a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs index fd07e4883..5fa1f1270 100644 --- a/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs +++ b/tests/Elastic.Changelog.Tests/Evaluation/ChangelogPrEvaluationServiceTests.cs @@ -520,7 +520,7 @@ public async Task EvaluatePr_WithProductLabels_OutputsProductsAndNoTable() } [Fact] - public async Task EvaluatePr_WithoutProductLabels_OutputsProductLabelTable() + public async Task EvaluatePr_WithoutProductLabels_ReturnsNoLabelAndOutputsProductLabelTable() { await WriteMinimalConfig(Path.Join(Paths.WorkingDirectoryRoot.FullName, "config", "changelog.yml"), ConfigWithProducts); var service = CreateService(); @@ -532,10 +532,113 @@ public async Task EvaluatePr_WithoutProductLabels_OutputsProductLabelTable() var result = await service.EvaluatePr(Collector, args, CancellationToken.None); result.Should().BeTrue(); - VerifyOutputSet("status", "proceed"); + VerifyOutputSet("status", "no-label"); + VerifyOutputSet("should-generate", "false"); A.CallTo(() => _mockCore.SetOutputAsync("products", A._)).MustNotHaveHappened(); A.CallTo(() => _mockCore.SetOutputAsync("product-label-table", A.That.Contains("@Product:ECH"))).MustHaveHappened(); A.CallTo(() => _mockCore.SetOutputAsync("product-label-table", A.That.Contains("cloud-hosted"))).MustHaveHappened(); + A.CallTo(() => _mockCore.SetOutputAsync("label-table", A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluatePr_SingleProductConfigured_WithoutLabel_AutoAssignsProduct() + { + var singleProductConfig = """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug" + breaking-change: "type:breaking" + enhancement: + deprecation: + docs: + known-issue: + other: + regression: + security: + products: + kibana: "@Product:Kibana" + """; + await WriteMinimalConfig(content: singleProductConfig); + var service = CreateService(); + var args = DefaultArgs(prLabels: ["type:feature"]); + + var result = await service.EvaluatePr(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("status", "proceed"); + VerifyOutputSet("products", "kibana"); + A.CallTo(() => _mockCore.SetOutputAsync("product-label-table", A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluatePr_SingleProductConfigured_WithMultipleLabelAliases_AutoAssignsProduct() + { + // Multi-label aliasing for the same product is still a single-product config — should + // not require any explicit label on the PR. + var configWithAliases = """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug" + breaking-change: "type:breaking" + enhancement: + deprecation: + docs: + known-issue: + other: + regression: + security: + products: + kibana: + - "@Product:Kibana" + - "@kibana" + """; + await WriteMinimalConfig(content: configWithAliases); + var service = CreateService(); + var args = DefaultArgs(prLabels: ["type:feature"]); + + var result = await service.EvaluatePr(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("status", "proceed"); + VerifyOutputSet("products", "kibana"); + A.CallTo(() => _mockCore.SetOutputAsync("product-label-table", A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task EvaluatePr_WithoutProductLabels_WithDefaultProducts_ReturnsProceed() + { + var configWithDefaults = """ + pivot: + types: + feature: "type:feature" + bug-fix: "type:bug" + breaking-change: "type:breaking" + enhancement: + deprecation: + docs: + known-issue: + other: + regression: + security: + products: + cloud-hosted: "@Product:ECH" + cloud-serverless: "@Product:ESS" + products: + default: + - product: cloud-hosted + lifecycle: ga + """; + await WriteMinimalConfig(content: configWithDefaults); + var service = CreateService(); + var args = DefaultArgs(prLabels: ["type:feature"]); + + var result = await service.EvaluatePr(Collector, args, CancellationToken.None); + + result.Should().BeTrue(); + VerifyOutputSet("status", "proceed"); + VerifyOutputSet("should-generate", "true"); } [Fact]