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 @@ -114,16 +114,31 @@ public async Task<bool> 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)
Expand All @@ -138,6 +153,22 @@ public async Task<bool> 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public enum PrEvaluationResult
[Display(Name = "success")]
Success,

/// <summary>No label matching a changelog type was found on the PR.</summary>
/// <summary>A required label (type or product) is missing from the PR.</summary>
[Display(Name = "no-label")]
NoLabel,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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<string>._)).MustNotHaveHappened();
A.CallTo(() => _mockCore.SetOutputAsync("product-label-table", A<string>.That.Contains("@Product:ECH"))).MustHaveHappened();
A.CallTo(() => _mockCore.SetOutputAsync("product-label-table", A<string>.That.Contains("cloud-hosted"))).MustHaveHappened();
A.CallTo(() => _mockCore.SetOutputAsync("label-table", A<string>._)).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<string>._)).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<string>._)).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]
Expand Down
Loading