From 242bcd4dc2d88ac7f621fa57b5683337deefa8b9 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Wed, 28 Jan 2026 23:01:23 +0530 Subject: [PATCH 1/9] Add support for maximum or average values in metrics --- MIGRATION-GUIDE.md | 241 ++++++++++++++++++ .../metrics/model/ElementFormatter.java | 13 + .../metrics/model/MetricAggregation.java | 43 ++++ .../metrics/steps/CoverageQualityGate.java | 48 ++++ .../steps/CoverageQualityGateEvaluator.java | 112 +++++++- .../metrics/steps/CoverageReporter.java | 4 +- .../metrics/steps/CoverageXmlStream.java | 18 +- .../steps/CoverageQualityGate/config.jelly | 4 + .../CoverageQualityGate/config.properties | 1 + .../CoverageQualityGate/help-aggregation.html | 11 + .../CoverageQualityGateEvaluatorTest.java | 16 ++ 11 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 MIGRATION-GUIDE.md create mode 100644 plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java create mode 100644 plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/help-aggregation.html diff --git a/MIGRATION-GUIDE.md b/MIGRATION-GUIDE.md new file mode 100644 index 000000000..1a98bb92c --- /dev/null +++ b/MIGRATION-GUIDE.md @@ -0,0 +1,241 @@ +# Migration Guide: Metric Aggregation Support + +## Overview + +This guide explains how to migrate from the deprecated `COMPLEXITY_MAXIMUM` metric to the new aggregation-based approach introduced to fix [JENKINS-75323](https://issues.jenkins.io/browse/JENKINS-75323). + +## Background + +Previously, the plugin supported specific metric variants like `COMPLEXITY_MAXIMUM` that were removed from the underlying coverage-model library (version 0.66.0). This caused pipeline jobs to fail with: + +``` +IllegalArgumentException: No enum constant edu.hm.hafner.coverage.Metric.COMPLEXITY_MAXIMUM +``` + +## New Approach: Metric Aggregation + +Instead of having separate metrics for different aggregation modes, the plugin now supports an `aggregation` parameter on quality gates. This allows you to specify how metrics should be computed across the codebase. + +### Available Aggregation Modes + +- **TOTAL** (default): Sums up all values (e.g., total lines of code, total complexity) +- **MAXIMUM**: Takes the maximum value from all methods/classes (e.g., highest complexity) +- **AVERAGE**: Computes the arithmetic mean of all values (e.g., average complexity per method) + +## Migration Examples + +### Pipeline Script (Declarative) + +**Old Configuration** (deprecated): + +```groovy +recordCoverage( + qualityGates: [ + [metric: 'COMPLEXITY_MAXIMUM', threshold: 10.0] + ] +) +``` + +**New Configuration**: + +```groovy +recordCoverage( + qualityGates: [ + [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 10.0] + ] +) +``` + +### Pipeline Script (Scripted) + +**Old Configuration** (deprecated): + +```groovy +publishCoverage( + qualityGates: [ + [metric: 'COMPLEXITY_MAXIMUM', threshold: 10.0] + ] +) +``` + +**New Configuration**: + +```groovy +publishCoverage( + qualityGates: [ + [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 10.0] + ] +) +``` + +### Job DSL + +**Old Configuration** (deprecated): + +```groovy +publishers { + recordCoverage { + qualityGates { + qualityGate { + metric('COMPLEXITY_MAXIMUM') + threshold(10.0) + } + } + } +} +``` + +**New Configuration**: + +```groovy +publishers { + recordCoverage { + qualityGates { + qualityGate { + metric('CYCLOMATIC_COMPLEXITY') + aggregation('MAXIMUM') + threshold(10.0) + } + } + } +} +``` + +## Metrics Supporting Aggregation + +The aggregation parameter can be used with the following software metrics: + +- **CYCLOMATIC_COMPLEXITY**: Cyclomatic complexity (McCabe) +- **CYCLOMATIC_COMPLEXITY_DENSITY**: Complexity per line of code +- **COGNITIVE_COMPLEXITY**: Cognitive complexity +- **NCSS**: Non-Commenting Source Statements +- **NPATH_COMPLEXITY**: NPath complexity +- **LOC**: Lines of Code + +Note: Coverage metrics (LINE, BRANCH, MUTATION, etc.) always use TOTAL aggregation and do not support MAXIMUM or AVERAGE modes. + +## Examples by Use Case + +### 1. Enforce Maximum Cyclomatic Complexity + +**Objective**: Ensure no method has cyclomatic complexity above 10 + +```groovy +recordCoverage( + qualityGates: [ + [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 10.0] + ] +) +``` + +### 2. Maintain Average Code Complexity + +**Objective**: Keep average cyclomatic complexity below 5 across all methods + +```groovy +recordCoverage( + qualityGates: [ + [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'AVERAGE', threshold: 5.0] + ] +) +``` + +### 3. Limit Total Complexity + +**Objective**: Keep total cyclomatic complexity of the project below 1000 + +```groovy +recordCoverage( + qualityGates: [ + [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'TOTAL', threshold: 1000.0] + ] +) +``` + +### 4. Multiple Quality Gates + +**Objective**: Combine different aggregation modes for comprehensive quality control + +```groovy +recordCoverage( + qualityGates: [ + [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 15.0], + [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'AVERAGE', threshold: 5.0], + [metric: 'LINE', threshold: 80.0] // Coverage metrics always use TOTAL + ] +) +``` + +## UI Configuration + +When configuring quality gates through the Jenkins UI: + +1. Select the **Metric** (e.g., "Cyclomatic Complexity") +2. Select the **Aggregation** mode: + - **Total**: Sum of all values (default) + - **Maximum**: Highest value found + - **Average**: Mean of all values +3. Set the **Threshold** value +4. Choose the **Baseline** (PROJECT, MODIFIED_LINES, etc.) +5. Set the **Criticality** (FAILURE, UNSTABLE, NOTE) + +## Backward Compatibility + +### Automatic Migration + +Existing build configurations that reference `COMPLEXITY_MAXIMUM` will be automatically migrated when deserialized: + +- `COMPLEXITY_MAXIMUM` → `CYCLOMATIC_COMPLEXITY` with `aggregation = TOTAL` + +**Note**: This automatic migration maintains backward compatibility but does **not** preserve the original MAXIMUM aggregation semantics. You must manually update your configuration to use `aggregation: 'MAXIMUM'` to restore the original behavior. + +### Why Manual Update is Required + +The automatic migration uses TOTAL (sum) instead of MAXIMUM because: + +1. TOTAL is the safe default that works for all metrics +2. The plugin cannot determine the original intent from the metric name alone +3. Users should explicitly review and update their quality gates + +### Update Checklist + +For each quality gate using `COMPLEXITY_MAXIMUM`: + +- [ ] Change `metric` from `COMPLEXITY_MAXIMUM` to `CYCLOMATIC_COMPLEXITY` +- [ ] Add `aggregation: 'MAXIMUM'` parameter +- [ ] Review and adjust threshold if needed +- [ ] Test the pipeline to verify the quality gate works as expected + +## Troubleshooting + +### Quality Gate Not Evaluating Correctly + +If your quality gate is not evaluating as expected: + +1. **Check the aggregation mode**: Ensure you've specified the correct aggregation (TOTAL, MAXIMUM, or AVERAGE) +2. **Verify metric support**: Only software metrics support MAXIMUM/AVERAGE aggregation +3. **Review threshold values**: MAXIMUM thresholds are typically much lower than TOTAL thresholds +4. **Check build logs**: The plugin logs the aggregated value used for evaluation + +### Build Still Failing with "No enum constant" + +If you're still getting the `IllegalArgumentException`: + +1. Ensure you've updated **all** quality gates in your configuration +2. Check for multiple `recordCoverage` or `publishCoverage` steps in your pipeline +3. Verify Job DSL configurations have been regenerated +4. Clear the Jenkins workspace and rebuild + +## Additional Resources + +- [GitHub Issue #639](https://github.com/jenkinsci/coverage-plugin/issues/639) +- [JENKINS-75323](https://issues.jenkins.io/browse/JENKINS-75323) +- [Plugin Documentation](https://plugins.jenkins.io/coverage/) + +## Questions or Issues? + +If you encounter problems during migration or have questions about the new aggregation feature: + +1. Check the [plugin documentation](https://plugins.jenkins.io/coverage/) +2. Search existing [GitHub issues](https://github.com/jenkinsci/coverage-plugin/issues) +3. Create a new issue with your pipeline configuration and error logs diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java index f5cd76ec0..b6a400b07 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java @@ -426,4 +426,17 @@ public ListBoxModel getBaselineItems() { private void add(final ListBoxModel options, final Baseline baseline) { options.add(getDisplayName(baseline), baseline.name()); } + + /** + * Returns all available aggregation modes as a {@link ListBoxModel}. + * + * @return the aggregation modes in a {@link ListBoxModel} + */ + public ListBoxModel getAggregationItems() { + var options = new ListBoxModel(); + for (MetricAggregation aggregation : MetricAggregation.values()) { + options.add(aggregation.name(), aggregation.name()); + } + return options; + } } diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java new file mode 100644 index 000000000..54b8c33e9 --- /dev/null +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java @@ -0,0 +1,43 @@ +package io.jenkins.plugins.coverage.metrics.model; + +import edu.hm.hafner.coverage.Metric; + +/** + * Defines the aggregation mode for software metrics that can be aggregated in different ways (e.g., cyclomatic + * complexity can be reported as total, maximum, or average). For coverage metrics, this aggregation is not applicable + * and will be ignored. + * + * @author Akash Manna + */ +public enum MetricAggregation { + /** The total value of the metric (sum of all values). */ + TOTAL, + /** The maximum value of the metric. */ + MAXIMUM, + /** The average value of the metric. */ + AVERAGE; + + /** + * Returns whether the specified metric supports aggregation modes. + * + * @param metric + * the metric to check + * + * @return {@code true} if the metric supports aggregation modes, {@code false} otherwise + */ + public static boolean isSupported(final Metric metric) { + return !metric.isCoverage(); + } + + /** + * Returns the default aggregation mode for the specified metric. + * + * @param metric + * the metric to get the default aggregation for + * + * @return the default aggregation mode + */ + public static MetricAggregation getDefault(final Metric metric) { + return TOTAL; + } +} diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java index 56d760481..64b2c48eb 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java @@ -14,6 +14,7 @@ import io.jenkins.plugins.coverage.metrics.model.Baseline; import io.jenkins.plugins.coverage.metrics.model.ElementFormatter; +import io.jenkins.plugins.coverage.metrics.model.MetricAggregation; import io.jenkins.plugins.util.JenkinsFacade; import io.jenkins.plugins.util.QualityGate; @@ -32,6 +33,7 @@ public class CoverageQualityGate extends QualityGate { private final Metric metric; private Baseline baseline = Baseline.PROJECT; + private MetricAggregation aggregation = MetricAggregation.TOTAL; /** * Creates a new instance of {@link CoverageQualityGate}. @@ -62,6 +64,16 @@ public CoverageQualityGate(final Metric metric) { setCriticality(criticality); } + CoverageQualityGate(final double threshold, final Metric metric, + final Baseline baseline, final QualityGateCriticality criticality, + final MetricAggregation aggregation) { + this(metric, threshold); + + setBaseline(baseline); + setCriticality(criticality); + setAggregation(aggregation); + } + /** * Sets the baseline that will be used for the quality gate evaluation. * @@ -73,6 +85,20 @@ public final void setBaseline(final Baseline baseline) { this.baseline = baseline; } + /** + * Sets the aggregation mode for software metrics (total, maximum, or average). This is only applicable for + * software metrics like cyclomatic complexity. For coverage metrics, this setting is ignored. + * + * @param aggregation + * the aggregation mode to use + */ + @DataBoundSetter + public final void setAggregation(final MetricAggregation aggregation) { + if (MetricAggregation.isSupported(metric)) { + this.aggregation = aggregation; + } + } + /** * Returns a human-readable name of the quality gate. * @@ -80,6 +106,10 @@ public final void setBaseline(final Baseline baseline) { */ @Override public String getName() { + if (MetricAggregation.isSupported(metric) && aggregation != MetricAggregation.TOTAL) { + return "%s - %s (%s)".formatted(FORMATTER.getDisplayName(getBaseline()), + FORMATTER.getDisplayName(getMetric()), aggregation); + } return "%s - %s".formatted(FORMATTER.getDisplayName(getBaseline()), FORMATTER.getDisplayName(getMetric())); } @@ -92,6 +122,10 @@ public Baseline getBaseline() { return baseline; } + public MetricAggregation getAggregation() { + return aggregation; + } + /** * Descriptor of the {@link CoverageQualityGate}. */ @@ -141,5 +175,19 @@ public ListBoxModel doFillBaselineItems() { } return new ListBoxModel(); } + + /** + * Returns a model with all {@link MetricAggregation aggregation modes}. + * + * @return a model with all {@link MetricAggregation aggregation modes}. + */ + @POST + @SuppressWarnings("unused") // used by Stapler view data binding + public ListBoxModel doFillAggregationItems() { + if (jenkins.hasPermission(Jenkins.READ)) { + return FORMATTER.getAggregationItems(); + } + return new ListBoxModel(); + } } } diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java index aabf52dcc..cd90c7604 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java @@ -2,9 +2,15 @@ import java.util.Collection; import java.util.Locale; +import java.util.Optional; +import java.util.stream.Stream; + +import edu.hm.hafner.coverage.Node; +import edu.hm.hafner.coverage.Value; import io.jenkins.plugins.coverage.metrics.model.CoverageStatistics; import io.jenkins.plugins.coverage.metrics.model.ElementFormatter; +import io.jenkins.plugins.coverage.metrics.model.MetricAggregation; import io.jenkins.plugins.util.QualityGateEvaluator; import io.jenkins.plugins.util.QualityGateResult; import io.jenkins.plugins.util.QualityGateStatus; @@ -17,18 +23,35 @@ class CoverageQualityGateEvaluator extends QualityGateEvaluator { private static final ElementFormatter FORMATTER = new ElementFormatter(); private final CoverageStatistics statistics; + private final Node rootNode; CoverageQualityGateEvaluator(final Collection qualityGates, final CoverageStatistics statistics) { + this(qualityGates, statistics, null); + } + + CoverageQualityGateEvaluator(final Collection qualityGates, + final CoverageStatistics statistics, final Node rootNode) { super(qualityGates); this.statistics = statistics; + this.rootNode = rootNode; } @Override protected void evaluate(final CoverageQualityGate qualityGate, final QualityGateResult result) { var baseline = qualityGate.getBaseline(); - var possibleValue = statistics.getValue(baseline, qualityGate.getMetric()); + var metric = qualityGate.getMetric(); + var aggregation = qualityGate.getAggregation(); + + Optional possibleValue; + if (MetricAggregation.isSupported(metric) && aggregation != MetricAggregation.TOTAL && rootNode != null) { + possibleValue = computeAggregatedValue(rootNode, metric, aggregation, baseline); + } + else { + possibleValue = statistics.getValue(baseline, metric); + } + if (possibleValue.isPresent()) { var actualValue = possibleValue.get(); var status = actualValue.isOutOfValidRange( @@ -39,4 +62,91 @@ protected void evaluate(final CoverageQualityGate qualityGate, final QualityGate result.add(qualityGate, QualityGateStatus.INACTIVE, "n/a"); } } + + /** + * Computes an aggregated value (maximum or average) for a metric from the node tree. + * + * @param node + * the root node to compute from + * @param metric + * the metric to compute + * @param aggregation + * the aggregation mode (MAXIMUM or AVERAGE) + * @param baseline + * the baseline (currently only PROJECT is supported for custom aggregation) + * + * @return the computed value, or empty if not computable + */ + private Optional computeAggregatedValue(final Node node, final edu.hm.hafner.coverage.Metric metric, + final MetricAggregation aggregation, final io.jenkins.plugins.coverage.metrics.model.Baseline baseline) { + if (baseline != io.jenkins.plugins.coverage.metrics.model.Baseline.PROJECT) { + return statistics.getValue(baseline, metric); + } + + var allValues = collectLeafValues(node, metric).toList(); + + if (allValues.isEmpty()) { + return Optional.empty(); + } + + if (aggregation == MetricAggregation.MAXIMUM) { + return allValues.stream().reduce(Value::max); + } + else if (aggregation == MetricAggregation.AVERAGE) { + return computeAverage(allValues.stream()); + } + + return Optional.empty(); + } + + /** + * Collects all leaf values for a metric from a node tree. For metrics computed at the method level (like + * complexity), this collects values from all methods. For class-level metrics, it collects from all classes. + * + * @param node + * the node to start from + * @param metric + * the metric to collect + * + * @return a stream of all leaf values + */ + private Stream collectLeafValues(final Node node, final edu.hm.hafner.coverage.Metric metric) { + Stream nodeValue = node.getValue(metric).stream(); + + Stream childValues = node.getChildren().stream() + .flatMap(child -> collectLeafValues(child, metric)); + + if (node.getMetric() == edu.hm.hafner.coverage.Metric.METHOD + || node.getMetric() == edu.hm.hafner.coverage.Metric.CLASS) { + return Stream.concat(nodeValue, childValues); + } + + return childValues.findAny().isPresent() ? childValues : nodeValue; + } + + /** + * Computes the average of a stream of values. For integer metrics like complexity, this computes the arithmetic + * mean. For coverage metrics, this computes the average percentage. + * + * @param values + * the values to average + * + * @return the average value, or empty if no values + */ + private Optional computeAverage(final Stream values) { + var list = values.toList(); + if (list.isEmpty()) { + return Optional.empty(); + } + + var sum = list.stream().reduce(Value::add); + if (sum.isEmpty()) { + return Optional.empty(); + } + + var metric = list.get(0).getMetric(); + var totalValue = sum.get(); + + return Optional.of(new Value(metric, totalValue.asDouble() / list.size())); + } } diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java index 0776c2b37..1f612f139 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageReporter.java @@ -68,7 +68,7 @@ private CoverageBuildAction computeActionWithoutHistory( final FilteredLog log) throws InterruptedException { var statistics = new CoverageStatistics(rootNode.aggregateValues(), List.of(), List.of(), List.of(), EMPTY_VALUES, List.of()); - var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics); + var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics, rootNode); var qualityGateStatus = evaluator.evaluate(notifier, log); paintSourceFiles(build, workspace, sourceCodeEncoding, sourceCodeRetention, id, rootNode, @@ -122,7 +122,7 @@ private CoverageBuildAction computeCoverageBasedOnReferenceBuild( var statistics = new CoverageStatistics(overallValues, overallDelta, modifiedLinesValues, modifiedLinesDelta, modifiedFilesValues, modifiedFilesDelta); - var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics); + var evaluator = new CoverageQualityGateEvaluator(qualityGates, statistics, rootNode); var qualityGateResult = evaluator.evaluate(notifier, log); var filesToStore = computePaintedFiles(rootNode, sourceCodeRetention, log, modifiedLinesCoverageRoot); diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java index 2b85ca7ea..f3d10b4a3 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java @@ -109,7 +109,7 @@ static void registerConverters(final XStream2 xStream) { xStream.registerConverter(new FractionConverter()); xStream.registerConverter(new SimpleConverter<>(Value.class, Value::serialize, Value::valueOf)); - xStream.registerConverter(new SimpleConverter<>(Metric.class, Metric::name, Metric::valueOf)); + xStream.registerConverter(new SimpleConverter<>(Metric.class, Metric::name, CoverageXmlStream::metricValueOf)); } @Override @@ -117,6 +117,22 @@ protected Node createDefaultValue() { return new ModuleNode("Empty"); } + /** + * Converts a string to a {@link Metric} value. Handles legacy metric names like COMPLEXITY_MAXIMUM by mapping + * them to the new metric names. + * + * @param metricName + * the name of the metric + * + * @return the metric value + */ + private static Metric metricValueOf(final String metricName) { + if ("COMPLEXITY_MAXIMUM".equals(metricName)) { + return Metric.CYCLOMATIC_COMPLEXITY; + } + return Metric.valueOf(metricName); + } + /** * {@link Converter} for {@link Fraction} instances so that only the values will be serialized. After reading the * values back from the stream, the string representation will be converted to an actual instance again. diff --git a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly index 1de4e6a86..9701f063a 100644 --- a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly +++ b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.jelly @@ -13,6 +13,10 @@ + + + + diff --git a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties index 7344b478e..4b76ed960 100644 --- a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties +++ b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/config.properties @@ -1,4 +1,5 @@ title.threshold=Threshold title.baseline=Baseline title.metric=Metric +title.aggregation=Aggregation title.warning=Step or Build Result diff --git a/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/help-aggregation.html b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/help-aggregation.html new file mode 100644 index 000000000..0520fd95e --- /dev/null +++ b/plugin/src/main/resources/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate/help-aggregation.html @@ -0,0 +1,11 @@ +
+ Defines how to aggregate the metric values for software metrics (e.g., cyclomatic complexity). +
    +
  • TOTAL: Sum of all values (default)
  • +
  • MAXIMUM: Maximum value found in any method or class
  • +
  • AVERAGE: Average value across all methods or classes
  • +
+ This setting is only applicable for software metrics like cyclomatic complexity, cognitive complexity, and NPath + complexity. For coverage metrics (e.g., line coverage), this setting is ignored and the aggregated coverage + percentage is always used. +
diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java index f33fd6b6d..902e3446e 100644 --- a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java @@ -311,6 +311,22 @@ void shouldAddAllQualityGates() { assertThatStatusWillBeOverwritten(evaluator); } + @Test + void shouldSupportMaximumAggregation() { + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics()); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + private static void assertThatStatusWillBeOverwritten(final CoverageQualityGateEvaluator evaluator) { var log = new FilteredLog("Errors"); var result = evaluator.evaluate(new NullResultHandler(), log); From e553fb7734cf1c71710bb6cffc6bea008bc7beee Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Wed, 28 Jan 2026 23:08:56 +0530 Subject: [PATCH 2/9] Refactor CoverageQualityGateEvaluator to use simplified imports for Metric and Baseline --- MIGRATION-GUIDE.md | 241 ------------------ .../steps/CoverageQualityGateEvaluator.java | 14 +- 2 files changed, 8 insertions(+), 247 deletions(-) delete mode 100644 MIGRATION-GUIDE.md diff --git a/MIGRATION-GUIDE.md b/MIGRATION-GUIDE.md deleted file mode 100644 index 1a98bb92c..000000000 --- a/MIGRATION-GUIDE.md +++ /dev/null @@ -1,241 +0,0 @@ -# Migration Guide: Metric Aggregation Support - -## Overview - -This guide explains how to migrate from the deprecated `COMPLEXITY_MAXIMUM` metric to the new aggregation-based approach introduced to fix [JENKINS-75323](https://issues.jenkins.io/browse/JENKINS-75323). - -## Background - -Previously, the plugin supported specific metric variants like `COMPLEXITY_MAXIMUM` that were removed from the underlying coverage-model library (version 0.66.0). This caused pipeline jobs to fail with: - -``` -IllegalArgumentException: No enum constant edu.hm.hafner.coverage.Metric.COMPLEXITY_MAXIMUM -``` - -## New Approach: Metric Aggregation - -Instead of having separate metrics for different aggregation modes, the plugin now supports an `aggregation` parameter on quality gates. This allows you to specify how metrics should be computed across the codebase. - -### Available Aggregation Modes - -- **TOTAL** (default): Sums up all values (e.g., total lines of code, total complexity) -- **MAXIMUM**: Takes the maximum value from all methods/classes (e.g., highest complexity) -- **AVERAGE**: Computes the arithmetic mean of all values (e.g., average complexity per method) - -## Migration Examples - -### Pipeline Script (Declarative) - -**Old Configuration** (deprecated): - -```groovy -recordCoverage( - qualityGates: [ - [metric: 'COMPLEXITY_MAXIMUM', threshold: 10.0] - ] -) -``` - -**New Configuration**: - -```groovy -recordCoverage( - qualityGates: [ - [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 10.0] - ] -) -``` - -### Pipeline Script (Scripted) - -**Old Configuration** (deprecated): - -```groovy -publishCoverage( - qualityGates: [ - [metric: 'COMPLEXITY_MAXIMUM', threshold: 10.0] - ] -) -``` - -**New Configuration**: - -```groovy -publishCoverage( - qualityGates: [ - [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 10.0] - ] -) -``` - -### Job DSL - -**Old Configuration** (deprecated): - -```groovy -publishers { - recordCoverage { - qualityGates { - qualityGate { - metric('COMPLEXITY_MAXIMUM') - threshold(10.0) - } - } - } -} -``` - -**New Configuration**: - -```groovy -publishers { - recordCoverage { - qualityGates { - qualityGate { - metric('CYCLOMATIC_COMPLEXITY') - aggregation('MAXIMUM') - threshold(10.0) - } - } - } -} -``` - -## Metrics Supporting Aggregation - -The aggregation parameter can be used with the following software metrics: - -- **CYCLOMATIC_COMPLEXITY**: Cyclomatic complexity (McCabe) -- **CYCLOMATIC_COMPLEXITY_DENSITY**: Complexity per line of code -- **COGNITIVE_COMPLEXITY**: Cognitive complexity -- **NCSS**: Non-Commenting Source Statements -- **NPATH_COMPLEXITY**: NPath complexity -- **LOC**: Lines of Code - -Note: Coverage metrics (LINE, BRANCH, MUTATION, etc.) always use TOTAL aggregation and do not support MAXIMUM or AVERAGE modes. - -## Examples by Use Case - -### 1. Enforce Maximum Cyclomatic Complexity - -**Objective**: Ensure no method has cyclomatic complexity above 10 - -```groovy -recordCoverage( - qualityGates: [ - [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 10.0] - ] -) -``` - -### 2. Maintain Average Code Complexity - -**Objective**: Keep average cyclomatic complexity below 5 across all methods - -```groovy -recordCoverage( - qualityGates: [ - [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'AVERAGE', threshold: 5.0] - ] -) -``` - -### 3. Limit Total Complexity - -**Objective**: Keep total cyclomatic complexity of the project below 1000 - -```groovy -recordCoverage( - qualityGates: [ - [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'TOTAL', threshold: 1000.0] - ] -) -``` - -### 4. Multiple Quality Gates - -**Objective**: Combine different aggregation modes for comprehensive quality control - -```groovy -recordCoverage( - qualityGates: [ - [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'MAXIMUM', threshold: 15.0], - [metric: 'CYCLOMATIC_COMPLEXITY', aggregation: 'AVERAGE', threshold: 5.0], - [metric: 'LINE', threshold: 80.0] // Coverage metrics always use TOTAL - ] -) -``` - -## UI Configuration - -When configuring quality gates through the Jenkins UI: - -1. Select the **Metric** (e.g., "Cyclomatic Complexity") -2. Select the **Aggregation** mode: - - **Total**: Sum of all values (default) - - **Maximum**: Highest value found - - **Average**: Mean of all values -3. Set the **Threshold** value -4. Choose the **Baseline** (PROJECT, MODIFIED_LINES, etc.) -5. Set the **Criticality** (FAILURE, UNSTABLE, NOTE) - -## Backward Compatibility - -### Automatic Migration - -Existing build configurations that reference `COMPLEXITY_MAXIMUM` will be automatically migrated when deserialized: - -- `COMPLEXITY_MAXIMUM` → `CYCLOMATIC_COMPLEXITY` with `aggregation = TOTAL` - -**Note**: This automatic migration maintains backward compatibility but does **not** preserve the original MAXIMUM aggregation semantics. You must manually update your configuration to use `aggregation: 'MAXIMUM'` to restore the original behavior. - -### Why Manual Update is Required - -The automatic migration uses TOTAL (sum) instead of MAXIMUM because: - -1. TOTAL is the safe default that works for all metrics -2. The plugin cannot determine the original intent from the metric name alone -3. Users should explicitly review and update their quality gates - -### Update Checklist - -For each quality gate using `COMPLEXITY_MAXIMUM`: - -- [ ] Change `metric` from `COMPLEXITY_MAXIMUM` to `CYCLOMATIC_COMPLEXITY` -- [ ] Add `aggregation: 'MAXIMUM'` parameter -- [ ] Review and adjust threshold if needed -- [ ] Test the pipeline to verify the quality gate works as expected - -## Troubleshooting - -### Quality Gate Not Evaluating Correctly - -If your quality gate is not evaluating as expected: - -1. **Check the aggregation mode**: Ensure you've specified the correct aggregation (TOTAL, MAXIMUM, or AVERAGE) -2. **Verify metric support**: Only software metrics support MAXIMUM/AVERAGE aggregation -3. **Review threshold values**: MAXIMUM thresholds are typically much lower than TOTAL thresholds -4. **Check build logs**: The plugin logs the aggregated value used for evaluation - -### Build Still Failing with "No enum constant" - -If you're still getting the `IllegalArgumentException`: - -1. Ensure you've updated **all** quality gates in your configuration -2. Check for multiple `recordCoverage` or `publishCoverage` steps in your pipeline -3. Verify Job DSL configurations have been regenerated -4. Clear the Jenkins workspace and rebuild - -## Additional Resources - -- [GitHub Issue #639](https://github.com/jenkinsci/coverage-plugin/issues/639) -- [JENKINS-75323](https://issues.jenkins.io/browse/JENKINS-75323) -- [Plugin Documentation](https://plugins.jenkins.io/coverage/) - -## Questions or Issues? - -If you encounter problems during migration or have questions about the new aggregation feature: - -1. Check the [plugin documentation](https://plugins.jenkins.io/coverage/) -2. Search existing [GitHub issues](https://github.com/jenkinsci/coverage-plugin/issues) -3. Create a new issue with your pipeline configuration and error logs diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java index cd90c7604..df37a6650 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java @@ -5,9 +5,11 @@ import java.util.Optional; import java.util.stream.Stream; +import edu.hm.hafner.coverage.Metric; import edu.hm.hafner.coverage.Node; import edu.hm.hafner.coverage.Value; +import io.jenkins.plugins.coverage.metrics.model.Baseline; import io.jenkins.plugins.coverage.metrics.model.CoverageStatistics; import io.jenkins.plugins.coverage.metrics.model.ElementFormatter; import io.jenkins.plugins.coverage.metrics.model.MetricAggregation; @@ -77,9 +79,9 @@ protected void evaluate(final CoverageQualityGate qualityGate, final QualityGate * * @return the computed value, or empty if not computable */ - private Optional computeAggregatedValue(final Node node, final edu.hm.hafner.coverage.Metric metric, - final MetricAggregation aggregation, final io.jenkins.plugins.coverage.metrics.model.Baseline baseline) { - if (baseline != io.jenkins.plugins.coverage.metrics.model.Baseline.PROJECT) { + private Optional computeAggregatedValue(final Node node, final Metric metric, + final MetricAggregation aggregation, final Baseline baseline) { + if (baseline != Baseline.PROJECT) { return statistics.getValue(baseline, metric); } @@ -110,14 +112,14 @@ else if (aggregation == MetricAggregation.AVERAGE) { * * @return a stream of all leaf values */ - private Stream collectLeafValues(final Node node, final edu.hm.hafner.coverage.Metric metric) { + private Stream collectLeafValues(final Node node, final Metric metric) { Stream nodeValue = node.getValue(metric).stream(); Stream childValues = node.getChildren().stream() .flatMap(child -> collectLeafValues(child, metric)); - if (node.getMetric() == edu.hm.hafner.coverage.Metric.METHOD - || node.getMetric() == edu.hm.hafner.coverage.Metric.CLASS) { + if (node.getMetric() == Metric.METHOD + || node.getMetric() == Metric.CLASS) { return Stream.concat(nodeValue, childValues); } From 86aeedd22a7a777b52ccac82c49a6a178ae42e58 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Wed, 28 Jan 2026 23:42:14 +0530 Subject: [PATCH 3/9] Enhance CoverageQualityGateEvaluator to support maximum and average aggregations in metrics --- .../steps/CoverageQualityGateEvaluator.java | 19 +- .../CoverageQualityGateEvaluatorTest.java | 249 ++++++++++++++++++ 2 files changed, 259 insertions(+), 9 deletions(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java index df37a6650..4edca55cb 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java @@ -1,6 +1,7 @@ package io.jenkins.plugins.coverage.metrics.steps; import java.util.Collection; +import java.util.List; import java.util.Locale; import java.util.Optional; import java.util.stream.Stream; @@ -95,7 +96,7 @@ private Optional computeAggregatedValue(final Node node, final Metric met return allValues.stream().reduce(Value::max); } else if (aggregation == MetricAggregation.AVERAGE) { - return computeAverage(allValues.stream()); + return computeAverage(allValues); } return Optional.empty(); @@ -123,11 +124,12 @@ private Stream collectLeafValues(final Node node, final Metric metric) { return Stream.concat(nodeValue, childValues); } - return childValues.findAny().isPresent() ? childValues : nodeValue; + var childValuesList = childValues.toList(); + return childValuesList.isEmpty() ? nodeValue : childValuesList.stream(); } /** - * Computes the average of a stream of values. For integer metrics like complexity, this computes the arithmetic + * Computes the average of a list of values. For integer metrics like complexity, this computes the arithmetic * mean. For coverage metrics, this computes the average percentage. * * @param values @@ -135,20 +137,19 @@ private Stream collectLeafValues(final Node node, final Metric metric) { * * @return the average value, or empty if no values */ - private Optional computeAverage(final Stream values) { - var list = values.toList(); - if (list.isEmpty()) { + private Optional computeAverage(final List values) { + if (values.isEmpty()) { return Optional.empty(); } - var sum = list.stream().reduce(Value::add); + var sum = values.stream().reduce(Value::add); if (sum.isEmpty()) { return Optional.empty(); } - var metric = list.get(0).getMetric(); + var metric = values.get(0).getMetric(); var totalValue = sum.get(); - return Optional.of(new Value(metric, totalValue.asDouble() / list.size())); + return Optional.of(new Value(metric, totalValue.asDouble() / values.size())); } } diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java index 902e3446e..ea92a2606 100644 --- a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java @@ -4,7 +4,13 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import edu.hm.hafner.coverage.ClassNode; +import edu.hm.hafner.coverage.Coverage.CoverageBuilder; +import edu.hm.hafner.coverage.MethodNode; import edu.hm.hafner.coverage.Metric; +import edu.hm.hafner.coverage.ModuleNode; +import edu.hm.hafner.coverage.Node; +import edu.hm.hafner.coverage.Value; import edu.hm.hafner.util.FilteredLog; import java.util.ArrayList; @@ -327,6 +333,249 @@ void shouldSupportMaximumAggregation() { assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); } + @Test + void shouldSupportMaximumAggregationWithRootNode() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(15.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // The maximum complexity in the tree is 20, which is > 15.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldSupportAverageAggregation() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.AVERAGE); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // The average complexity is (5 + 10 + 15 + 20) / 4 = 12.5, which is > 10.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldHandleTotalAggregationAsDefault() { + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(149.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + // Do not set aggregation, should default to TOTAL + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics()); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Should use total value from statistics (150) which is > 149.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful().hasMessages( + "[Overall project - Cyclomatic Complexity]: ≪Unstable≫ - (Actual value: 150, Quality gate: 149.00)"); + } + + @Test + void shouldNotApplyAggregationForCoverageMetrics() { + var rootNode = createNodeTreeWithCoverage(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(60.0, Metric.LINE, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // For coverage metrics, aggregation should be ignored and use statistics value (50%) + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful().hasMessages( + "[Overall project - Line Coverage]: ≪Unstable≫ - (Actual value: 50.00%, Quality gate: 60.00)"); + } + + @Test + void shouldSupportMultipleAggregationsInSameEvaluation() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var maxGate = new CoverageQualityGate(15.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + maxGate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(maxGate); + + var avgGate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.FAILURE); + avgGate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.AVERAGE); + qualityGates.add(avgGate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Both should fail: maximum is 20 > 15.0 and average is 12.5 > 10.0 + assertThat(result).hasOverallStatus(QualityGateStatus.FAILED).isNotSuccessful(); + } + + @Test + void shouldHandleEmptyNodeTree() { + var rootNode = new ModuleNode("empty"); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Empty node tree has no values to aggregate, should mark as inactive + assertThat(result).hasOverallStatus(QualityGateStatus.INACTIVE).isInactive(); + } + + @Test + void shouldHandleNullRootNodeForAggregation() { + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + // No root node provided + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics()); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // Should fall back to statistics value when root node is null + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldFallBackToStatisticsForNonProjectBaseline() { + var rootNode = createNodeTreeWithComplexity(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(10.0, Metric.CYCLOMATIC_COMPLEXITY, Baseline.MODIFIED_LINES, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // For non-PROJECT baselines, should use statistics even with custom aggregation + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + @Test + void shouldSupportMaximumAggregationForLinesOfCode() { + var rootNode = createNodeTreeWithLOC(); + Collection qualityGates = new ArrayList<>(); + + var gate = new CoverageQualityGate(55.0, Metric.LOC, Baseline.PROJECT, QualityGateCriticality.UNSTABLE); + gate.setAggregation(io.jenkins.plugins.coverage.metrics.model.MetricAggregation.MAXIMUM); + qualityGates.add(gate); + + var evaluator = new CoverageQualityGateEvaluator(qualityGates, createStatistics(), rootNode); + + var log = new FilteredLog("Errors"); + var result = evaluator.evaluate(new NullResultHandler(), log); + + // The maximum LOC is 60, which is > 55.0 + assertThat(result).hasOverallStatus(QualityGateStatus.WARNING).isNotSuccessful(); + } + + /** + * Creates a node tree with cyclomatic complexity values for testing. + * Structure: Module -> Class -> Methods with complexity values: 5, 10, 15, 20 + */ + private static Node createNodeTreeWithComplexity() { + var root = new ModuleNode("TestProject"); + var classNode = new ClassNode("TestClass"); + root.addChild(classNode); + + var method1 = new MethodNode("method1", "()V"); + method1.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 5)); + classNode.addChild(method1); + + var method2 = new MethodNode("method2", "()V"); + method2.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 10)); + classNode.addChild(method2); + + var method3 = new MethodNode("method3", "()V"); + method3.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 15)); + classNode.addChild(method3); + + var method4 = new MethodNode("method4", "()V"); + method4.addValue(new Value(Metric.CYCLOMATIC_COMPLEXITY, 20)); + classNode.addChild(method4); + + return root; + } + + /** + * Creates a node tree with LOC values for testing. + * Structure: Module -> Class -> Methods with LOC values: 30, 40, 50, 60 + */ + private static Node createNodeTreeWithLOC() { + var root = new ModuleNode("TestProject"); + var classNode = new ClassNode("TestClass"); + root.addChild(classNode); + + var method1 = new MethodNode("method1", "()V"); + method1.addValue(new Value(Metric.LOC, 30)); + classNode.addChild(method1); + + var method2 = new MethodNode("method2", "()V"); + method2.addValue(new Value(Metric.LOC, 40)); + classNode.addChild(method2); + + var method3 = new MethodNode("method3", "()V"); + method3.addValue(new Value(Metric.LOC, 50)); + classNode.addChild(method3); + + var method4 = new MethodNode("method4", "()V"); + method4.addValue(new Value(Metric.LOC, 60)); + classNode.addChild(method4); + + return root; + } + + /** + * Creates a node tree with coverage values for testing. + * Structure: Module -> Class -> Methods with line coverage + */ + private static Node createNodeTreeWithCoverage() { + var root = new ModuleNode("TestProject"); + var classNode = new ClassNode("TestClass"); + root.addChild(classNode); + + var method1 = new MethodNode("method1", "()V"); + method1.addValue(new CoverageBuilder().withMetric(Metric.LINE).withCovered(5).withMissed(5).build()); + classNode.addChild(method1); + + var method2 = new MethodNode("method2", "()V"); + method2.addValue(new CoverageBuilder().withMetric(Metric.LINE).withCovered(8).withMissed(2).build()); + classNode.addChild(method2); + + return root; + } + private static void assertThatStatusWillBeOverwritten(final CoverageQualityGateEvaluator evaluator) { var log = new FilteredLog("Errors"); var result = evaluator.evaluate(new NullResultHandler(), log); From 2361e1fba9685a0dd7aa51c6bde79539183b222d Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sat, 31 Jan 2026 11:28:54 +0530 Subject: [PATCH 4/9] add legacy complexity maximum constant and refactor usage in CoverageXmlStream --- .../plugins/coverage/metrics/steps/CoverageQualityGate.java | 1 + .../plugins/coverage/metrics/steps/CoverageXmlStream.java | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java index 64b2c48eb..0f071ddd7 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java @@ -25,6 +25,7 @@ * * @author Johannes Walter */ +@SuppressWarnings("PMD.DataClass") public class CoverageQualityGate extends QualityGate { @Serial private static final long serialVersionUID = -397278599489426668L; diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java index f3d10b4a3..1cc8dc1d0 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java @@ -48,6 +48,7 @@ */ @SuppressWarnings("PMD.CouplingBetweenObjects") class CoverageXmlStream extends AbstractXmlStream { + private static final String LEGACY_COMPLEXITY_MAXIMUM = "COMPLEXITY_MAXIMUM"; private static final Collector ARRAY_JOINER = Collectors.joining(", ", "[", "]"); private static String[] toArray(final String value) { @@ -127,7 +128,7 @@ protected Node createDefaultValue() { * @return the metric value */ private static Metric metricValueOf(final String metricName) { - if ("COMPLEXITY_MAXIMUM".equals(metricName)) { + if (LEGACY_COMPLEXITY_MAXIMUM.equals(metricName)) { return Metric.CYCLOMATIC_COMPLEXITY; } return Metric.valueOf(metricName); From e5953fa73e10cd36bec25f343a1d57b2b5fa9ca6 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sat, 31 Jan 2026 11:42:58 +0530 Subject: [PATCH 5/9] add Javadoc comments to fix checkstyle warnings --- .../metrics/steps/CoverageQualityGateEvaluatorTest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java index ea92a2606..69b25ccb5 100644 --- a/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java +++ b/plugin/src/test/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluatorTest.java @@ -503,6 +503,8 @@ void shouldSupportMaximumAggregationForLinesOfCode() { /** * Creates a node tree with cyclomatic complexity values for testing. * Structure: Module -> Class -> Methods with complexity values: 5, 10, 15, 20 + * + * @return a node tree with complexity values */ private static Node createNodeTreeWithComplexity() { var root = new ModuleNode("TestProject"); @@ -531,6 +533,8 @@ private static Node createNodeTreeWithComplexity() { /** * Creates a node tree with LOC values for testing. * Structure: Module -> Class -> Methods with LOC values: 30, 40, 50, 60 + * + * @return a node tree with LOC values */ private static Node createNodeTreeWithLOC() { var root = new ModuleNode("TestProject"); @@ -559,6 +563,8 @@ private static Node createNodeTreeWithLOC() { /** * Creates a node tree with coverage values for testing. * Structure: Module -> Class -> Methods with line coverage + * + * @return a node tree with coverage values */ private static Node createNodeTreeWithCoverage() { var root = new ModuleNode("TestProject"); From 49ea83607b0372fa4b060e995a42723a68373125 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sat, 31 Jan 2026 11:57:40 +0530 Subject: [PATCH 6/9] refactor MetricAggregation to simplify getDefault method by removing metric parameter --- .../plugins/coverage/metrics/model/MetricAggregation.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java index 54b8c33e9..ce487d185 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java @@ -30,14 +30,11 @@ public static boolean isSupported(final Metric metric) { } /** - * Returns the default aggregation mode for the specified metric. - * - * @param metric - * the metric to get the default aggregation for + * Returns the default aggregation mode. * * @return the default aggregation mode */ - public static MetricAggregation getDefault(final Metric metric) { + public static MetricAggregation getDefault() { return TOTAL; } } From 01b5cf62375a178a005428f925b7e1306b3a80d0 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sat, 31 Jan 2026 12:13:56 +0530 Subject: [PATCH 7/9] add @CheckForNull annotation to rootNode for null safety --- .../coverage/metrics/steps/CoverageQualityGateEvaluator.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java index 4edca55cb..20789ffa2 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java @@ -9,6 +9,7 @@ import edu.hm.hafner.coverage.Metric; import edu.hm.hafner.coverage.Node; import edu.hm.hafner.coverage.Value; +import edu.umd.cs.findbugs.annotations.CheckForNull; import io.jenkins.plugins.coverage.metrics.model.Baseline; import io.jenkins.plugins.coverage.metrics.model.CoverageStatistics; @@ -26,6 +27,7 @@ class CoverageQualityGateEvaluator extends QualityGateEvaluator { private static final ElementFormatter FORMATTER = new ElementFormatter(); private final CoverageStatistics statistics; + @CheckForNull private final Node rootNode; CoverageQualityGateEvaluator(final Collection qualityGates, @@ -34,7 +36,7 @@ class CoverageQualityGateEvaluator extends QualityGateEvaluator qualityGates, - final CoverageStatistics statistics, final Node rootNode) { + final CoverageStatistics statistics, @CheckForNull final Node rootNode) { super(qualityGates); this.statistics = statistics; From 97f2e3834b6570acb28f41b2ec2d4d7ed9ff92d6 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sun, 15 Feb 2026 09:01:56 +0530 Subject: [PATCH 8/9] refactor CoverageQualityGate and CoverageQualityGateEvaluator to simplify aggregation handling --- .../plugins/coverage/metrics/steps/CoverageQualityGate.java | 4 +--- .../metrics/steps/CoverageQualityGateEvaluator.java | 6 +++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java index 0f071ddd7..4b431cafe 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java @@ -95,9 +95,7 @@ public final void setBaseline(final Baseline baseline) { */ @DataBoundSetter public final void setAggregation(final MetricAggregation aggregation) { - if (MetricAggregation.isSupported(metric)) { - this.aggregation = aggregation; - } + this.aggregation = aggregation; } /** diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java index 20789ffa2..e70ade817 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java @@ -50,7 +50,7 @@ protected void evaluate(final CoverageQualityGate qualityGate, final QualityGate var aggregation = qualityGate.getAggregation(); Optional possibleValue; - if (MetricAggregation.isSupported(metric) && aggregation != MetricAggregation.TOTAL && rootNode != null) { + if (MetricAggregation.isSupported(metric) && aggregation != MetricAggregation.TOTAL) { possibleValue = computeAggregatedValue(rootNode, metric, aggregation, baseline); } else { @@ -88,6 +88,10 @@ private Optional computeAggregatedValue(final Node node, final Metric met return statistics.getValue(baseline, metric); } + if (node == null) { + return statistics.getValue(baseline, metric); + } + var allValues = collectLeafValues(node, metric).toList(); if (allValues.isEmpty()) { From cfeefc5b332362cebc8cdc3d5f2c3788b4a644e3 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sun, 15 Feb 2026 09:24:15 +0530 Subject: [PATCH 9/9] add null check --- .../coverage/metrics/steps/CoverageQualityGateEvaluator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java index e70ade817..365d7e983 100644 --- a/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java +++ b/plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java @@ -82,7 +82,7 @@ protected void evaluate(final CoverageQualityGate qualityGate, final QualityGate * * @return the computed value, or empty if not computable */ - private Optional computeAggregatedValue(final Node node, final Metric metric, + private Optional computeAggregatedValue(@CheckForNull final Node node, final Metric metric, final MetricAggregation aggregation, final Baseline baseline) { if (baseline != Baseline.PROJECT) { return statistics.getValue(baseline, metric);