From a73eb1d2b2a29c1b9428054a4ba0eaf9d8e6d22a Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Sat, 8 Aug 2026 14:36:10 +0530 Subject: [PATCH 1/2] Provide support for advanced action filtering --- .../forensics/reference/ActionFilter.java | 98 ++++++++ .../reference/ReferenceRecorder.java | 14 +- .../reference/SimpleReferenceRecorder.java | 70 +++++- .../SimpleReferenceRecorder/config.jelly | 8 + .../SimpleReferenceRecorder/config.properties | 2 + .../help-requiredAction.html | 23 ++ .../help-requiredActionId.html | 15 ++ .../reference/ReferenceRecorderTest.java | 68 +++++ .../SimpleReferenceRecorderITest.java | 72 ++++++ .../SimpleReferenceRecorderTest.java | 236 +++++++++++++++++- 10 files changed, 595 insertions(+), 11 deletions(-) create mode 100644 src/main/java/io/jenkins/plugins/forensics/reference/ActionFilter.java create mode 100644 src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredAction.html create mode 100644 src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredActionId.html diff --git a/src/main/java/io/jenkins/plugins/forensics/reference/ActionFilter.java b/src/main/java/io/jenkins/plugins/forensics/reference/ActionFilter.java new file mode 100644 index 00000000..03e7079d --- /dev/null +++ b/src/main/java/io/jenkins/plugins/forensics/reference/ActionFilter.java @@ -0,0 +1,98 @@ +package io.jenkins.plugins.forensics.reference; + +import org.apache.commons.lang3.StringUtils; + +import edu.umd.cs.findbugs.annotations.CheckForNull; + +import java.util.Arrays; +import java.util.Optional; + +import hudson.model.Action; +import hudson.model.Run; + +/** + * Filters builds by the {@link Action actions} they provide. An action matches if its class name equals the required + * type (the fully qualified or the simple name of the action, one of its supertypes or one of its interfaces) and if + * its {@link Action#getUrlName() URL name} equals the required ID. Both criteria are optional: an empty value matches + * every action. + * + * @author Akash Manna + */ +class ActionFilter { + private final String type; + private final String id; + + ActionFilter(final String type, final String id) { + this.type = StringUtils.stripToEmpty(type); + this.id = StringUtils.stripToEmpty(id); + } + + boolean isEnabled() { + return StringUtils.isNotBlank(type) || StringUtils.isNotBlank(id); + } + + /** + * Returns whether the specified build provides a matching action. + * + * @param build + * the build to check + * + * @return {@code true} if the build provides a matching action or if this filter is disabled + */ + boolean accepts(final Run build) { + return !isEnabled() || build.getAllActions().stream().anyMatch(this::matches); + } + + /** + * Returns the first build that provides a matching action, starting with the specified build and continuing with + * its predecessors. + * + * @param start + * the first build to check + * + * @return the matching build (or empty if no such build exists) + */ + Optional> findFirstAcceptedBuild(final Run start) { + for (Run build = start; build != null; build = build.getPreviousCompletedBuild()) { + if (accepts(build)) { + return Optional.of(build); + } + } + return Optional.empty(); + } + + /** + * Returns a suffix that can be appended to a log message to describe the requirement of this filter. + * + * @return the suffix, or an empty string if this filter is disabled + */ + String getRequirementSuffix() { + return isEnabled() ? " and provide an action %s".formatted(this) : StringUtils.EMPTY; + } + + private boolean matches(final Action action) { + return (StringUtils.isBlank(type) || isOfRequiredType(action.getClass())) + && (StringUtils.isBlank(id) || id.equals(action.getUrlName())); + } + + private boolean isOfRequiredType(@CheckForNull final Class candidate) { + if (candidate == null) { + return false; + } + return type.equals(candidate.getName()) + || type.equals(candidate.getSimpleName()) + || isOfRequiredType(candidate.getSuperclass()) + || Arrays.stream(candidate.getInterfaces()).anyMatch(this::isOfRequiredType); + } + + @Override + public String toString() { + if (StringUtils.isBlank(id)) { + return "of type '%s'".formatted(type); + } + if (StringUtils.isBlank(type)) { + return "with ID '%s'".formatted(id); + } + return "of type '%s' with ID '%s'".formatted(type, id); + } +} diff --git a/src/main/java/io/jenkins/plugins/forensics/reference/ReferenceRecorder.java b/src/main/java/io/jenkins/plugins/forensics/reference/ReferenceRecorder.java index 40f24743..d831aa54 100644 --- a/src/main/java/io/jenkins/plugins/forensics/reference/ReferenceRecorder.java +++ b/src/main/java/io/jenkins/plugins/forensics/reference/ReferenceRecorder.java @@ -163,10 +163,20 @@ private Optional searchForReferenceBuildWithRequiredStatus(final } logger.logInfo("No reference build with required status found that contains matching commits"); if (isLatestBuildIfNotFound()) { + var filter = createActionFilter(); + var fallback = filter.findFirstAcceptedBuild(lastCompletedBuild); + if (fallback.isEmpty()) { + logger.logInfo("-> no build that provides an action %s found in the history of '%s'", + filter, lastCompletedBuild.getDisplayName()); + + return Optional.empty(); + } + + var latestBuild = fallback.get(); logger.logInfo("Falling back to latest completed build of reference job: '%s'", - lastCompletedBuild.getDisplayName()); + latestBuild.getDisplayName()); - return Optional.of(new ReferenceBuild(run, logger.getInfoMessages(), getRequiredResult(), lastCompletedBuild)); + return Optional.of(new ReferenceBuild(run, logger.getInfoMessages(), getRequiredResult(), latestBuild)); } return Optional.empty(); } diff --git a/src/main/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder.java b/src/main/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder.java index 0738b0f6..06cdb3c0 100644 --- a/src/main/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder.java +++ b/src/main/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder.java @@ -19,6 +19,7 @@ import hudson.FilePath; import hudson.Launcher; import hudson.model.AbstractProject; +import hudson.model.Action; import hudson.model.BuildableItem; import hudson.model.Item; import hudson.model.Job; @@ -69,6 +70,8 @@ public class SimpleReferenceRecorder extends Recorder implements SimpleBuildStep private String referenceJob = StringUtils.EMPTY; private Result requiredResult = Result.UNSTABLE; // @since 2.4.0 private boolean considerRunningBuild; + private String requiredAction = StringUtils.EMPTY; // @since 2.6.0 + private String requiredActionId = StringUtils.EMPTY; // @since 2.6.0 /** * Creates a new instance of {@link SimpleReferenceRecorder}. @@ -100,6 +103,8 @@ protected Object readResolve() { if (requiredResult == null) { requiredResult = Result.UNSTABLE; } + requiredAction = StringUtils.stripToEmpty(requiredAction); + requiredActionId = StringUtils.stripToEmpty(requiredActionId); return this; } @@ -173,6 +178,48 @@ protected boolean hasRequiredResult(final Run referenceBuild) { return result != null && result.isBetterOrEqualTo(requiredResult); } + /** + * Sets the type of an {@link Action} that the reference build must provide: builds without such an action will be + * skipped. The type is matched against the fully qualified or the simple class name of the action, its supertypes + * and its interfaces. + * + * @param requiredAction + * the class name of the required action, or an empty string if the type of the action is not relevant + */ + @DataBoundSetter + public void setRequiredAction(final String requiredAction) { + this.requiredAction = StringUtils.stripToEmpty(requiredAction); + } + + public String getRequiredAction() { + return requiredAction; + } + + /** + * Sets the ID of an {@link Action} that the reference build must provide: builds without such an action will be + * skipped. The ID of an action is given by its {@link Action#getUrlName() URL name}. + * + * @param requiredActionId + * the ID of the required action, or an empty string if the ID of the action is not relevant + */ + @DataBoundSetter + public void setRequiredActionId(final String requiredActionId) { + this.requiredActionId = StringUtils.stripToEmpty(requiredActionId); + } + + public String getRequiredActionId() { + return requiredActionId; + } + + /** + * Creates the filter that selects the reference build by the actions it provides. + * + * @return the action filter + */ + ActionFilter createActionFilter() { + return new ActionFilter(requiredAction, requiredActionId); + } + @Override public BuildStepMonitor getRequiredMonitorService() { return BuildStepMonitor.NONE; @@ -301,17 +348,26 @@ protected void logNoBuildFound(final Job reference, final FilteredLog log) * @return the reference build that satisfies the required status (or empty if no such build is found) */ protected Optional getReferenceBuildWithRequiredStatus(final Run run, final Run start, final FilteredLog log) { + var filter = createActionFilter(); + if (filter.isEnabled()) { + log.logInfo("Considering only builds that provide an action %s", filter); + } for (Run reference = start; reference != null; reference = reference.getPreviousCompletedBuild()) { if (hasRequiredResult(reference)) { - log.logInfo("-> %s '%s' has a result %s", - getBuildName(start, reference), - reference.getDisplayName(), reference.getResult()); - - return Optional.of(new ReferenceBuild(run, log.getInfoMessages(), requiredResult, reference)); + if (filter.accepts(reference)) { + log.logInfo("-> %s '%s' has a result %s", + getBuildName(start, reference), + reference.getDisplayName(), reference.getResult()); + + return Optional.of(new ReferenceBuild(run, log.getInfoMessages(), requiredResult, reference)); + } + log.logInfo("-> skipping %s '%s' since it does not provide an action %s", + StringUtils.uncapitalize(getBuildName(start, reference)), + reference.getDisplayName(), filter); } } - log.logInfo("-> ignoring reference build '%s' or one of its predecessors since none have a result of %s or better", - start.getDisplayName(), requiredResult); + log.logInfo("-> ignoring reference build '%s' or one of its predecessors since none have a result of %s or better%s", + start.getDisplayName(), requiredResult, filter.getRequirementSuffix()); return Optional.empty(); } diff --git a/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.jelly b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.jelly index 83d64ee8..1fa050b1 100644 --- a/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.jelly +++ b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.jelly @@ -10,5 +10,13 @@ + + + + + + + + diff --git a/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.properties b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.properties index 9624e32a..1b3d00af 100644 --- a/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.properties +++ b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/config.properties @@ -1,3 +1,5 @@ title.referenceJob=Reference Job title.requiredResult=Required Build Result title.considerRunningBuild=Consider running builds as reference +title.requiredAction=Required Action Type +title.requiredActionId=Required Action ID diff --git a/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredAction.html b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredAction.html new file mode 100644 index 00000000..c38a61eb --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredAction.html @@ -0,0 +1,23 @@ +Some plugins do not record their results in every build: code coverage, for example, can be disabled on a +per-build basis. If the automatically selected reference build does not contain such a report, then no delta +report can be computed at all - the delta results are then silently empty. + +

+ With this parameter you can restrict the search for a reference build to those builds that provide a specific + action (i.e., a specific report). All builds that do not provide such an action will be skipped, and the search + continues with the predecessors of these builds. +

+ +

+ The action can be specified either by its fully qualified class name or by its simple class name. Supertypes and + interfaces of the actual action are considered as well, so it is possible to filter for a whole family of + actions. Some examples: +

+
+
io.jenkins.plugins.coverage.metrics.steps.CoverageBuildAction
+
The reference build must contain a code coverage report of the coverage plugin
+
CoverageBuildAction
+
The same filter, using the simple class name
+
<empty>
+
The type of the action is not relevant (this is the default behavior if unset)
+
diff --git a/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredActionId.html b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredActionId.html new file mode 100644 index 00000000..fa764cb6 --- /dev/null +++ b/src/main/resources/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorder/help-requiredActionId.html @@ -0,0 +1,15 @@ +Plugins that can record several results in the same build (like the coverage plugin, which can report a code +coverage and a mutation coverage result) use an ID to distinguish these results. This ID is exposed as the URL +name of the corresponding action. + +

+ With this parameter you can restrict the search for a reference build to those builds that provide an action + with the given ID. All builds that do not provide such an action will be skipped, and the search continues with + the predecessors of these builds. +

+ +

+ This parameter can be combined with the required action type: then the reference build must provide an action + that matches both the type and the ID. If the type is left empty, then the ID alone is used to select the + matching action. If this parameter is left empty, then the ID of the action is not relevant. +

diff --git a/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java b/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java index efc94c64..10deb272 100644 --- a/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java +++ b/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java @@ -2,16 +2,19 @@ import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.Issue; import edu.hm.hafner.util.FilteredLog; import edu.hm.hafner.util.VisibleForTesting; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.Optional; import java.util.function.Consumer; import org.jenkinsci.plugins.workflow.multibranch.WorkflowMultiBranchProject; +import hudson.model.Action; import hudson.model.Item; import hudson.model.Job; import hudson.model.Result; @@ -21,6 +24,7 @@ import jenkins.scm.api.mixin.ChangeRequestSCMHead; import io.jenkins.plugins.forensics.reference.ReferenceRecorder.ScmFacade; +import io.jenkins.plugins.forensics.reference.SimpleReferenceRecorderTest.CoverageReportAction; import io.jenkins.plugins.util.JenkinsFacade; import static io.jenkins.plugins.forensics.assertions.Assertions.*; @@ -308,6 +312,70 @@ void shouldNotFindReferenceJobForMultiBranchProject() { assertThat(referenceBuild.getReferenceBuild()).isEmpty(); } + /** + * Verifies that the fallback to the latest build of the reference job skips those builds that do not provide the + * required action. + */ + @Test + @Issue("JENKINS-72825") + void shouldFallBackToLatestBuildThatProvidesRequiredAction() { + var log = createLog(); + + Run build = mock(Run.class); + Job job = createJob(build); + var topLevel = createMultiBranch(job); + + var recorder = createSut(); + recorder.setLatestBuildIfNotFound(true); + recorder.setRequiredAction("CoverageReportAction"); + + var prBuild = configurePrJobAndBuild(recorder, topLevel, job); // 'find' returns no matching commits + var withReport = createBuild("with-report", Result.SUCCESS, new CoverageReportAction("coverage")); + when(prBuild.getPreviousCompletedBuild()).thenAnswer(a -> withReport); + + var referenceBuild = recorder.findReferenceBuild(build, log); + + assertThat(log.getInfoMessages()).contains( + "No reference build with required status found that contains matching commits", + "Falling back to latest completed build of reference job: 'with-report'"); + + assertThat(referenceBuild).hasReferenceBuildId("with-report"); + } + + /** + * Verifies that no reference build is returned if none of the builds in the history provides the required action, + * even if the fallback to the latest build has been enabled. + */ + @Test + @Issue("JENKINS-72825") + void shouldNotFallBackToLatestBuildIfRequiredActionIsMissing() { + var log = createLog(); + + Run build = mock(Run.class); + Job job = createJob(build); + var topLevel = createMultiBranch(job); + + var recorder = createSut(); + recorder.setLatestBuildIfNotFound(true); + recorder.setRequiredAction("CoverageReportAction"); + + configurePrJobAndBuild(recorder, topLevel, job); // 'find' returns no matching commits + + var referenceBuild = recorder.findReferenceBuild(build, log); + + assertThat(log.getInfoMessages()).contains( + "No reference build with required status found that contains matching commits", + "-> no build that provides an action of type 'CoverageReportAction' found in the history of 'pr-id'"); + + assertThat(referenceBuild).doesNotHaveReferenceBuild(); + } + + private Run createBuild(final String displayName, final Result result, final Action... actions) { + var build = createBuild(displayName, result); + when(build.getAllActions()).thenAnswer(i -> List.of(actions)); + return build; + } + private ReferenceRecorder createSut() { return createSut(mock(ScmFacade.class)); } diff --git a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java index 08484a45..8d2f29a1 100644 --- a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java +++ b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java @@ -11,11 +11,13 @@ import java.util.Arrays; import java.util.Optional; import java.util.StringJoiner; +import java.util.function.Consumer; import hudson.model.FreeStyleProject; import hudson.model.Result; import hudson.model.Run; +import io.jenkins.plugins.forensics.reference.SimpleReferenceRecorderTest.CoverageReportAction; import io.jenkins.plugins.util.IntegrationTestWithJenkinsPerSuite; import static org.assertj.core.api.Assertions.*; @@ -219,6 +221,70 @@ void shouldRunInDeclarativePipeline() { assertThat(findReferenceBuild(second)).contains(baseline); } + /** + * Reproduces the reported problem: the report (e.g., the code coverage) is recorded only in some builds of the + * reference job. Without a filter the latest build is selected as reference build, even if it does not contain the + * report at all, so no delta can be computed. With the filter the last build that actually recorded the report is + * selected. + */ + @Test + @Issue("JENKINS-72825") + void shouldSkipReferenceBuildsThatDoNotProvideTheRequiredAction() { + var reference = createFreeStyleProject(); + Run withReport = buildSuccessfully(reference); + withReport.addAction(new CoverageReportAction("coverage")); + Run withoutReport = buildSuccessfully(reference); // the report is disabled in this build + + var unfiltered = createJob(reference.getName()); + assertThat(findReferenceBuild(buildSuccessfully(unfiltered))).contains(withoutReport); + + var filtered = createJob(reference.getName(), + recorder -> recorder.setRequiredAction(CoverageReportAction.class.getName())); + Run current = buildSuccessfully(filtered); + + assertThat(findReferenceBuild(current)).contains(withReport); + assertThat(getConsoleLog(current)).contains( + "Considering only builds that provide an action of type '%s'".formatted( + CoverageReportAction.class.getName()), + "since it does not provide an action of type '%s'".formatted( + CoverageReportAction.class.getName())); + } + + @Test + @Issue("JENKINS-72825") + void shouldSelectReferenceBuildByActionId() { + var reference = createFreeStyleProject(); + Run codeCoverage = buildSuccessfully(reference); + codeCoverage.addAction(new CoverageReportAction("code-coverage")); + Run mutationCoverage = buildSuccessfully(reference); + mutationCoverage.addAction(new CoverageReportAction("mutation-coverage")); + + var job = createPipeline(); + job.setDefinition(createPipelineScript("node {\n" + + discoverReferenceJob(reference.getName(), "requiredActionId: 'code-coverage'") + + " }\n")); + + Run current = buildSuccessfully(job); + + assertThat(findReferenceBuild(current)).contains(codeCoverage); + assertThat(getConsoleLog(current)).contains( + "Considering only builds that provide an action with ID 'code-coverage'"); + } + + @Test + @Issue("JENKINS-72825") + void shouldFindNoReferenceBuildIfTheRequiredActionIsNeverRecorded() { + var reference = createFreeStyleProject(); + buildSuccessfully(reference); + + var job = createJob(reference.getName(), recorder -> recorder.setRequiredAction("NotExistingAction")); + Run current = buildSuccessfully(job); + + assertThat(findReferenceBuild(current)).isEmpty(); + assertThat(getConsoleLog(current)).contains( + "or better and provide an action of type 'NotExistingAction'"); + } + private String discoverReferenceJob(final String referenceJobName, final String... arguments) { var joiner = new StringJoiner(", ", ", ", "").setEmptyValue(""); Arrays.stream(arguments).forEach(joiner::add); @@ -231,9 +297,15 @@ private String discoverReferenceJob(final String referenceJobName, final String. } private FreeStyleProject createJob(final String referenceJobName) { + return createJob(referenceJobName, recorder -> { }); + } + + private FreeStyleProject createJob(final String referenceJobName, + final Consumer configuration) { var job = createFreeStyleProject(); var referenceRecorder = new SimpleReferenceRecorder(); referenceRecorder.setReferenceJob(referenceJobName); + configuration.accept(referenceRecorder); job.getPublishersList().add(referenceRecorder); return job; } diff --git a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java index 19f0db65..ba49d252 100644 --- a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java +++ b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java @@ -1,15 +1,21 @@ package io.jenkins.plugins.forensics.reference; -import java.util.Set; - +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; +import org.junitpioneer.jupiter.Issue; import edu.hm.hafner.util.FilteredLog; +import edu.umd.cs.findbugs.annotations.CheckForNull; + +import java.util.List; +import java.util.Set; +import hudson.model.Action; import hudson.model.BuildableItem; import hudson.model.FreeStyleBuild; import hudson.model.FreeStyleProject; import hudson.model.Item; +import hudson.model.ModelObject; import hudson.model.Result; import hudson.model.Run; import hudson.util.FormValidation; @@ -144,7 +150,233 @@ void shouldConsiderRunningBuilds() { assertThat(runningBuild).hasReferenceBuildId("reference-build-id"); } + @Test + @Issue("JENKINS-72825") + void shouldNotFilterByActionsByDefault() { + var recorder = new SimpleReferenceRecorder(); + + assertThat(recorder) + .hasRequiredAction(StringUtils.EMPTY) + .hasRequiredActionId(StringUtils.EMPTY); + assertThat(recorder.createActionFilter().isEnabled()).isFalse(); + assertThat(recorder.createActionFilter().accepts(mock(Run.class))).isTrue(); + } + + @Test + @Issue("JENKINS-72825") + void shouldStripWhitespaceFromActionFilter() { + var recorder = new SimpleReferenceRecorder(); + + recorder.setRequiredAction(" CoverageBuildAction "); + recorder.setRequiredActionId(" coverage "); + + assertThat(recorder) + .hasRequiredAction("CoverageBuildAction") + .hasRequiredActionId("coverage"); + assertThat(recorder.createActionFilter().isEnabled()).isTrue(); + } + + @Test + @Issue("JENKINS-72825") + void shouldSkipBuildsThatDoNotProvideTheRequiredActionType() { + var withoutReport = createBuild("no-report", Result.SUCCESS); + var withReport = createBuild("report", Result.SUCCESS, new CoverageReportAction("coverage")); + + var recorder = new SimpleReferenceRecorder(); + recorder.setRequiredAction(CoverageReportAction.class.getName()); + + var log = createLog(); + var referenceBuild = recorder.findReferenceBuild(createRunWithHistory(withoutReport, withReport), log); + + assertThat(referenceBuild).hasReferenceBuildId("report"); + assertThat(log.getInfoMessages()).contains( + "Considering only builds that provide an action of type '%s'".formatted( + CoverageReportAction.class.getName()), + "-> skipping build 'no-report' since it does not provide an action of type '%s'".formatted( + CoverageReportAction.class.getName()), + "-> Previous build 'report' has a result SUCCESS"); + } + + @Test + @Issue("JENKINS-72825") + void shouldMatchTheRequiredActionTypeUsingTheSimpleClassName() { + var withoutReport = createBuild("no-report", Result.SUCCESS); + var withReport = createBuild("report", Result.SUCCESS, new CoverageReportAction("coverage")); + + var recorder = new SimpleReferenceRecorder(); + recorder.setRequiredAction("CoverageReportAction"); + + var referenceBuild = recorder.findReferenceBuild( + createRunWithHistory(withoutReport, withReport), createLog()); + + assertThat(referenceBuild).hasReferenceBuildId("report"); + } + + @Test + @Issue("JENKINS-72825") + void shouldMatchSuperClassesAndInterfacesOfTheRequiredActionType() { + var withoutReport = createBuild("no-report", Result.SUCCESS); + var withReport = createBuild("report", Result.SUCCESS, + new CoverageReportAction.MutationCoverageReportAction("mutation-coverage")); + + var superClassRecorder = new SimpleReferenceRecorder(); + superClassRecorder.setRequiredAction(CoverageReportAction.class.getName()); + + assertThat(superClassRecorder.findReferenceBuild( + createRunWithHistory(withoutReport, withReport), createLog())) + .hasReferenceBuildId("report"); + + var interfaceRecorder = new SimpleReferenceRecorder(); + interfaceRecorder.setRequiredAction(ModelObject.class.getName()); // implemented by Action + + assertThat(interfaceRecorder.findReferenceBuild( + createRunWithHistory(withoutReport, withReport), createLog())) + .hasReferenceBuildId("report"); + } + + @Test + @Issue("JENKINS-72825") + void shouldSelectTheBuildThatProvidesAnActionWithTheRequiredId() { + var mutationCoverage = createBuild("mutation", Result.SUCCESS, new CoverageReportAction("mutation-coverage")); + var codeCoverage = createBuild("code", Result.SUCCESS, new CoverageReportAction("code-coverage")); + + var recorder = new SimpleReferenceRecorder(); + recorder.setRequiredActionId("code-coverage"); + + var log = createLog(); + var referenceBuild = recorder.findReferenceBuild( + createRunWithHistory(mutationCoverage, codeCoverage), log); + + assertThat(referenceBuild).hasReferenceBuildId("code"); + assertThat(log.getInfoMessages()).contains( + "Considering only builds that provide an action with ID 'code-coverage'", + "-> skipping build 'mutation' since it does not provide an action with ID 'code-coverage'"); + } + + @Test + @Issue("JENKINS-72825") + void shouldCombineTheRequiredActionTypeAndId() { + var wrongId = createBuild("wrong-id", Result.SUCCESS, new CoverageReportAction("mutation-coverage")); + var wrongType = createBuild("wrong-type", Result.SUCCESS); + var matching = createBuild("matching", Result.SUCCESS, new CoverageReportAction("code-coverage")); + + var recorder = new SimpleReferenceRecorder(); + recorder.setRequiredAction("CoverageReportAction"); + recorder.setRequiredActionId("code-coverage"); + + var log = createLog(); + var referenceBuild = recorder.findReferenceBuild( + createRunWithHistory(wrongId, wrongType, matching), log); + + assertThat(referenceBuild).hasReferenceBuildId("matching"); + assertThat(log.getInfoMessages()).contains( + "Considering only builds that provide an action of type 'CoverageReportAction' with ID 'code-coverage'", + "-> Previous build 'matching' has a result SUCCESS"); + } + + @Test + @Issue("JENKINS-72825") + void shouldCombineTheActionFilterWithTheRequiredResult() { + var failedWithReport = createBuild("failed", Result.FAILURE, new CoverageReportAction("coverage")); + var successfulWithoutReport = createBuild("no-report", Result.SUCCESS); + var successfulWithReport = createBuild("report", Result.SUCCESS, new CoverageReportAction("coverage")); + + var recorder = new SimpleReferenceRecorder(); + recorder.setRequiredResult(Result.SUCCESS); + recorder.setRequiredAction("CoverageReportAction"); + + var referenceBuild = recorder.findReferenceBuild( + createRunWithHistory(failedWithReport, successfulWithoutReport, successfulWithReport), createLog()); + + assertThat(referenceBuild).hasReferenceBuildId("report"); + } + + @Test + @Issue("JENKINS-72825") + void shouldFindNoReferenceBuildIfNoBuildProvidesTheRequiredAction() { + var first = createBuild("first", Result.SUCCESS); + var second = createBuild("second", Result.SUCCESS); + + var recorder = new SimpleReferenceRecorder(); + recorder.setRequiredAction("NotExistingAction"); + + var log = createLog(); + var referenceBuild = recorder.findReferenceBuild(createRunWithHistory(first, second), log); + + assertThat(referenceBuild).doesNotHaveReferenceBuild(); + assertThat(log.getInfoMessages()).contains( + "-> ignoring reference build 'first' or one of its predecessors since none have a result of " + + "UNSTABLE or better and provide an action of type 'NotExistingAction'"); + } + + /** + * Creates a run of a job that has the specified builds in its history: the first build of the array is the last + * completed build, all other builds are the predecessors (in the given order). + * + * @param builds + * the builds in the history of the reference job + * + * @return the current run that will search for a reference build in the history of its own job + */ + private Run createRunWithHistory(final FreeStyleBuild... builds) { + var job = mock(FreeStyleProject.class); + when(job.getDisplayName()).thenReturn("reference"); + when(job.getLastCompletedBuild()).thenReturn(builds[0]); + for (var i = 0; i < builds.length - 1; i++) { + when(builds[i].getPreviousCompletedBuild()).thenReturn(builds[i + 1]); + } + + var run = mock(Run.class); + when(run.getParent()).thenAnswer(i -> job); + return run; + } + + private FreeStyleBuild createBuild(final String id, final Result result, final Action... actions) { + FreeStyleBuild build = mock(FreeStyleBuild.class); + when(build.getResult()).thenReturn(result); + when(build.getDisplayName()).thenReturn(id); + when(build.getExternalizableId()).thenReturn(id); + when(build.getAllActions()).thenAnswer(i -> List.of(actions)); + return build; + } + private FilteredLog createLog() { return new FilteredLog("test"); } + + /** + * Simulates a report that is not recorded in every build (like the coverage report of the coverage plugin). The ID + * of the report is exposed as the URL name of this action. + * + * @author Akash Manna + */ + static class CoverageReportAction implements Action { + private final String id; + + CoverageReportAction(final String id) { + this.id = id; + } + + @Override @CheckForNull + public String getIconFileName() { + return null; + } + + @Override @CheckForNull + public String getDisplayName() { + return null; + } + + @Override + public String getUrlName() { + return id; + } + + /** Verifies that the supertypes of an action are considered by the filter as well. */ + static final class MutationCoverageReportAction extends CoverageReportAction { + MutationCoverageReportAction(final String id) { + super(id); + } + } + } } From 058bc57518de8d22babb3e9237087fd620344d19 Mon Sep 17 00:00:00 2001 From: Akash Manna Date: Mon, 10 Aug 2026 23:38:55 +0530 Subject: [PATCH 2/2] Remove unused CoverageReportAction import and add CoverageReportAction class for test simulation --- .../reference/CoverageReportAction.java | 46 +++++++++++++++++++ .../reference/ReferenceRecorderTest.java | 1 - .../SimpleReferenceRecorderITest.java | 1 - .../SimpleReferenceRecorderTest.java | 37 --------------- 4 files changed, 46 insertions(+), 39 deletions(-) create mode 100644 src/test/java/io/jenkins/plugins/forensics/reference/CoverageReportAction.java diff --git a/src/test/java/io/jenkins/plugins/forensics/reference/CoverageReportAction.java b/src/test/java/io/jenkins/plugins/forensics/reference/CoverageReportAction.java new file mode 100644 index 00000000..d209ed25 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/forensics/reference/CoverageReportAction.java @@ -0,0 +1,46 @@ +package io.jenkins.plugins.forensics.reference; + +import edu.umd.cs.findbugs.annotations.CheckForNull; + +import hudson.model.Action; + +/** + * Simulates a report that is not recorded in every build (like the code coverage report of the coverage plugin). The + * ID of the report is exposed as the URL name of this action, so that the tests can select a build by the type or by + * the ID of the report it provides. + * + * @author Akash Manna + */ +class CoverageReportAction implements Action { + private final String id; + + CoverageReportAction(final String id) { + this.id = id; + } + + @Override @CheckForNull + public String getIconFileName() { + return null; + } + + @Override @CheckForNull + public String getDisplayName() { + return null; + } + + @Override + public String getUrlName() { + return id; + } + + /** + * A subtype that verifies that the supertypes of an action are considered by the filter as well. + * + * @author Akash Manna + */ + static final class MutationCoverageReportAction extends CoverageReportAction { + MutationCoverageReportAction(final String id) { + super(id); + } + } +} diff --git a/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java b/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java index 10deb272..3025ae5e 100644 --- a/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java +++ b/src/test/java/io/jenkins/plugins/forensics/reference/ReferenceRecorderTest.java @@ -24,7 +24,6 @@ import jenkins.scm.api.mixin.ChangeRequestSCMHead; import io.jenkins.plugins.forensics.reference.ReferenceRecorder.ScmFacade; -import io.jenkins.plugins.forensics.reference.SimpleReferenceRecorderTest.CoverageReportAction; import io.jenkins.plugins.util.JenkinsFacade; import static io.jenkins.plugins.forensics.assertions.Assertions.*; diff --git a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java index 8d2f29a1..464a6859 100644 --- a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java +++ b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderITest.java @@ -17,7 +17,6 @@ import hudson.model.Result; import hudson.model.Run; -import io.jenkins.plugins.forensics.reference.SimpleReferenceRecorderTest.CoverageReportAction; import io.jenkins.plugins.util.IntegrationTestWithJenkinsPerSuite; import static org.assertj.core.api.Assertions.*; diff --git a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java index ba49d252..5f66dd0f 100644 --- a/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java +++ b/src/test/java/io/jenkins/plugins/forensics/reference/SimpleReferenceRecorderTest.java @@ -5,7 +5,6 @@ import org.junitpioneer.jupiter.Issue; import edu.hm.hafner.util.FilteredLog; -import edu.umd.cs.findbugs.annotations.CheckForNull; import java.util.List; import java.util.Set; @@ -343,40 +342,4 @@ private FreeStyleBuild createBuild(final String id, final Result result, final A private FilteredLog createLog() { return new FilteredLog("test"); } - - /** - * Simulates a report that is not recorded in every build (like the coverage report of the coverage plugin). The ID - * of the report is exposed as the URL name of this action. - * - * @author Akash Manna - */ - static class CoverageReportAction implements Action { - private final String id; - - CoverageReportAction(final String id) { - this.id = id; - } - - @Override @CheckForNull - public String getIconFileName() { - return null; - } - - @Override @CheckForNull - public String getDisplayName() { - return null; - } - - @Override - public String getUrlName() { - return id; - } - - /** Verifies that the supertypes of an action are considered by the filter as well. */ - static final class MutationCoverageReportAction extends CoverageReportAction { - MutationCoverageReportAction(final String id) { - super(id); - } - } - } }