Skip to content
Draft
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 @@ -426,4 +426,17 @@
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;

Check warning on line 440 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/ElementFormatter.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 436-440 are not covered by tests
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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.
*
* @return the default aggregation mode
*/
public static MetricAggregation getDefault() {
return TOTAL;

Check warning on line 38 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/model/MetricAggregation.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 38 is not covered by tests
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -24,6 +25,7 @@
*
* @author Johannes Walter
*/
@SuppressWarnings("PMD.DataClass")
public class CoverageQualityGate extends QualityGate {
@Serial
private static final long serialVersionUID = -397278599489426668L;
Expand All @@ -32,6 +34,7 @@

private final Metric metric;
private Baseline baseline = Baseline.PROJECT;
private MetricAggregation aggregation = MetricAggregation.TOTAL;

/**
* Creates a new instance of {@link CoverageQualityGate}.
Expand Down Expand Up @@ -62,6 +65,16 @@
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);
}

Check warning on line 76 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 71-76 are not covered by tests

/**
* Sets the baseline that will be used for the quality gate evaluation.
*
Expand All @@ -73,13 +86,31 @@
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.
*
* @return a human-readable name
*/
@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()));
}
Expand All @@ -92,6 +123,10 @@
return baseline;
}

public MetricAggregation getAggregation() {
return aggregation;
}

/**
* Descriptor of the {@link CoverageQualityGate}.
*/
Expand Down Expand Up @@ -141,5 +176,19 @@
}
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();

Check warning on line 191 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGate.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered lines

Lines 188-191 are not covered by tests
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
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;

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;
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;
Expand All @@ -17,18 +27,36 @@
class CoverageQualityGateEvaluator extends QualityGateEvaluator<CoverageQualityGate> {
private static final ElementFormatter FORMATTER = new ElementFormatter();
private final CoverageStatistics statistics;
@CheckForNull
private final Node rootNode;

CoverageQualityGateEvaluator(final Collection<? extends CoverageQualityGate> qualityGates,
final CoverageStatistics statistics) {
this(qualityGates, statistics, null);
}

CoverageQualityGateEvaluator(final Collection<? extends CoverageQualityGate> qualityGates,
final CoverageStatistics statistics, @CheckForNull 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<Value> 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(
Expand All @@ -39,4 +67,91 @@
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<Value> computeAggregatedValue(final Node node, final Metric metric,
final MetricAggregation aggregation, final Baseline baseline) {
if (baseline != 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) {

Check warning on line 100 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 100 is only partially covered, one branch is missing
return computeAverage(allValues);
}

return Optional.empty();

Check warning on line 104 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 104 is not covered by tests
}

/**
* 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<Value> collectLeafValues(final Node node, final Metric metric) {
Stream<Value> nodeValue = node.getValue(metric).stream();

Stream<Value> childValues = node.getChildren().stream()
.flatMap(child -> collectLeafValues(child, metric));

if (node.getMetric() == Metric.METHOD
|| node.getMetric() == Metric.CLASS) {
return Stream.concat(nodeValue, childValues);
}

var childValuesList = childValues.toList();
return childValuesList.isEmpty() ? nodeValue : childValuesList.stream();
}

/**
* 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
* the values to average
*
* @return the average value, or empty if no values
*/
private Optional<Value> computeAverage(final List<Value> values) {
if (values.isEmpty()) {

Check warning on line 143 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 143 is only partially covered, one branch is missing
return Optional.empty();

Check warning on line 144 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 144 is not covered by tests
}

var sum = values.stream().reduce(Value::add);
if (sum.isEmpty()) {

Check warning on line 148 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 148 is only partially covered, one branch is missing
return Optional.empty();

Check warning on line 149 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageQualityGateEvaluator.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 149 is not covered by tests
}

var metric = values.get(0).getMetric();
var totalValue = sum.get();

return Optional.of(new Value(metric, totalValue.asDouble() / values.size()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ private CoverageBuildAction computeActionWithoutHistory(
final FilteredLog log) throws InterruptedException {
var statistics = new CoverageStatistics(rootNode.aggregateValues(),
List.of(), List.<Difference>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,
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
*/
@SuppressWarnings("PMD.CouplingBetweenObjects")
class CoverageXmlStream extends AbstractXmlStream<Node> {
private static final String LEGACY_COMPLEXITY_MAXIMUM = "COMPLEXITY_MAXIMUM";
private static final Collector<CharSequence, ?, String> ARRAY_JOINER = Collectors.joining(", ", "[", "]");

private static String[] toArray(final String value) {
Expand Down Expand Up @@ -109,14 +110,30 @@

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
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 (LEGACY_COMPLEXITY_MAXIMUM.equals(metricName)) {

Check warning on line 131 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Partially covered line

Line 131 is only partially covered, one branch is missing
return Metric.CYCLOMATIC_COMPLEXITY;

Check warning on line 132 in plugin/src/main/java/io/jenkins/plugins/coverage/metrics/steps/CoverageXmlStream.java

View check run for this annotation

ci.jenkins.io / Code Coverage

Not covered line

Line 132 is not covered by tests
}
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
<f:select/>
</f:entry>

<f:entry title="${%title.aggregation}" field="aggregation">
<f:select/>
</f:entry>

<f:entry title="${%title.warning}" field="criticality">
<f:select/>
</f:entry>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
title.threshold=Threshold
title.baseline=Baseline
title.metric=Metric
title.aggregation=Aggregation
title.warning=Step or Build Result
Loading
Loading