diff --git a/pom.xml b/pom.xml index d5d160c..8b2a578 100644 --- a/pom.xml +++ b/pom.xml @@ -81,12 +81,42 @@ workflow-job 1.14 + + org.hamcrest + hamcrest-library + 1.3 + test + + + org.hamcrest + hamcrest-core + 1.3 + test + org.mockito mockito-core 1.10.19 test + + org.powermock + powermock-module-junit4 + 1.6.4 + test + + + org.powermock + powermock-api-mockito + 1.6.4 + test + + + org.mockito + mockito-all + + + org.apache.httpcomponents httpclient diff --git a/src/main/java/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction.java b/src/main/java/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction.java index 8116259..0ff467f 100644 --- a/src/main/java/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction.java +++ b/src/main/java/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction.java @@ -26,10 +26,11 @@ import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.HashMultimap; -import com.google.common.collect.ImmutableList; import com.google.common.collect.Multimap; import com.google.common.collect.Sets; +import hudson.model.AbstractBuild; import hudson.model.Action; +import hudson.model.Run; import hudson.scm.ChangeLogSet; import org.jenkinsci.plugins.workflow.job.WorkflowJob; import org.jenkinsci.plugins.workflow.job.WorkflowRun; @@ -40,6 +41,7 @@ public class AllChangesWorkflowAction implements Action { private WorkflowJob project; private int numChanges = 0; + private DependencyChangesAggregator aggregator; AllChangesWorkflowAction(WorkflowJob project) { this.project = project; @@ -63,40 +65,69 @@ public String getUrlName() { } /** - * Returns all changes which contribute to a build. + * Returns all changes which contribute to the given build. * - * @param build - * @return + * @param build the build from which to get dependency changes + * @return a map of change log sets to builds or empty map if none were found */ - public Multimap getAllChanges(WorkflowRun build) { - Set builds = getContributingBuilds(build); + public Multimap getAllChanges(WorkflowRun build) { + + Set builds = getContributingBuilds(build); + Multimap changes = ArrayListMultimap.create(); - for (WorkflowRun changedBuild : builds) { - for (ChangeLogSet changeLogSet : changedBuild.getChangeSets()) { - ChangeLogSet changeSet = (ChangeLogSet)changeLogSet; + for (Run changedBuild : builds) { + if (changedBuild instanceof WorkflowRun) { + for (ChangeLogSet changeLogSet : ((WorkflowRun) changedBuild).getChangeSets()) { + ChangeLogSet changeSet = (ChangeLogSet)changeLogSet; + for (ChangeLogSet.Entry entry : changeSet) { + changes.put(entry.getCommitId() + entry.getMsgAnnotated() + entry.getTimestamp(), entry); + } + } + } else if (changedBuild instanceof AbstractBuild) { + ChangeLogSet changeSet = ((AbstractBuild) changedBuild).getChangeSet(); for (ChangeLogSet.Entry entry : changeSet) { changes.put(entry.getCommitId() + entry.getMsgAnnotated() + entry.getTimestamp(), entry); } } } - Multimap change2Build = HashMultimap.create(); + + Multimap change2Build = HashMultimap.create(); for (String changeKey : changes.keySet()) { ChangeLogSet.Entry change = changes.get(changeKey).iterator().next(); for (ChangeLogSet.Entry entry : changes.get(changeKey)) { - change2Build.put(change, (WorkflowRun) entry.getParent().getRun()); + change2Build.put(change, entry.getParent().getRun()); } } + return change2Build; } /** - * Uses all ChangesAggregators to calculate the contributing builds + * Uses DependencyChangesAggregator to calculate the contributing builds. * - * @return all changes which contribute to the given build + * @param build the workflow build to get dependencies for + * @return all changed builds which contribute to the given build or empty set if none were found */ - public Set getContributingBuilds(WorkflowRun build) { - Set builds = Sets.newHashSet(); + public Set getContributingBuilds(WorkflowRun build) { + + Set builds = Sets.newHashSet(); builds.add(build); + + if (aggregator == null) { + aggregator = new DependencyChangesAggregator(); + } + + int size = 0; + // Saturate the build Set + do { + size = builds.size(); + Set newBuilds = Sets.newHashSet(); + for (Run depBuild : builds) { + newBuilds.addAll(aggregator.aggregateBuildsWithChanges(depBuild)); + } + builds.addAll(newBuilds); + } while (size < builds.size()); + return builds; } @@ -107,4 +138,8 @@ public WorkflowJob getProject() { public int getNumChanges() { return numChanges; } + + public void setAggregator(DependencyChangesAggregator aggregator) { + this.aggregator = aggregator; + } } diff --git a/src/main/java/org/jenkinsci/plugins/all_changes/ChangesAggregator.java b/src/main/java/org/jenkinsci/plugins/all_changes/ChangesAggregator.java index 277807b..33e6b66 100644 --- a/src/main/java/org/jenkinsci/plugins/all_changes/ChangesAggregator.java +++ b/src/main/java/org/jenkinsci/plugins/all_changes/ChangesAggregator.java @@ -26,16 +26,15 @@ import hudson.ExtensionList; import hudson.ExtensionPoint; -import hudson.model.AbstractBuild; -import jenkins.model.Jenkins; +import hudson.model.Run; import java.util.Collection; -public abstract class ChangesAggregator implements ExtensionPoint { - public abstract Collection aggregateBuildsWithChanges(AbstractBuild build); +public abstract class ChangesAggregator implements ExtensionPoint { + + public abstract Collection aggregateBuildsWithChanges(T build); public static ExtensionList all() { return Util.getInstance().getExtensionList(ChangesAggregator.class); } - } diff --git a/src/main/java/org/jenkinsci/plugins/all_changes/DependencyChangesAggregator.java b/src/main/java/org/jenkinsci/plugins/all_changes/DependencyChangesAggregator.java index ae8d5f9..11b80a5 100644 --- a/src/main/java/org/jenkinsci/plugins/all_changes/DependencyChangesAggregator.java +++ b/src/main/java/org/jenkinsci/plugins/all_changes/DependencyChangesAggregator.java @@ -28,22 +28,69 @@ import hudson.Extension; import hudson.model.AbstractBuild; import hudson.model.AbstractProject; +import hudson.model.Run; +import hudson.tasks.Fingerprinter; import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; import java.util.Map; /** * @author wolfs */ @Extension -public class DependencyChangesAggregator extends ChangesAggregator { +public class DependencyChangesAggregator extends ChangesAggregator { + @Override - public Collection aggregateBuildsWithChanges(AbstractBuild build) { - ImmutableList.Builder builder = ImmutableList.builder(); - Map depChanges = build.getDependencyChanges((AbstractBuild) build.getPreviousBuild()); + public Collection aggregateBuildsWithChanges(Run build) { + ImmutableList.Builder builder = ImmutableList.builder(); + Map depChanges = getDependencyChanges(build, build.getPreviousBuild()); for (AbstractBuild.DependencyChange depChange : depChanges.values()) { builder.addAll(depChange.getBuilds()); } return builder.build(); } + + /** + * Gets the changes in the dependency between two given builds using {@link Fingerprinter.FingerprintAction}. + * + *

This implements the functionality from {@link AbstractBuild#getDependencyChanges(AbstractBuild)} using + * {@link Run} instead of {@link AbstractBuild} as input build parameters, so that this aggregator can be used by + * the {@link AllChangesWorkflowAction} as well. + * + * @param build the current build to find dependencies to + * @param otherBuild another build to find dependencies from + * @return a map of projects to dependency changes or an empty map if there are no fingerprint actions or the other job is null + * @see AbstractBuild#getDependencyChanges(AbstractBuild) + */ + private Map getDependencyChanges(Run build, Run otherBuild) { + + if (otherBuild == null) { + return Collections.emptyMap(); + } + + Fingerprinter.FingerprintAction currentBuildFingerprints = build.getAction(Fingerprinter.FingerprintAction.class); + Fingerprinter.FingerprintAction previousBuildFingerprints = otherBuild.getAction(Fingerprinter.FingerprintAction.class); + + if (currentBuildFingerprints == null || previousBuildFingerprints == null) { + return Collections.emptyMap(); + } + + Map currentBuildDependencies = currentBuildFingerprints.getDependencies(true); + Map previousBuildDependencies = previousBuildFingerprints.getDependencies(true); + + Map changes = new HashMap<>(); + + for (Map.Entry entry : previousBuildDependencies.entrySet()) { + AbstractProject dependencyProject = entry.getKey(); + Integer oldNumber = entry.getValue(); + Integer newNumber = currentBuildDependencies.get(dependencyProject); + if (newNumber != null && oldNumber.compareTo(newNumber) < 0) { + changes.put(dependencyProject, new AbstractBuild.DependencyChange(dependencyProject, oldNumber, newNumber)); + } + } + + return changes; + } } diff --git a/src/main/java/org/jenkinsci/plugins/all_changes/SubProjectChangesAggregator.java b/src/main/java/org/jenkinsci/plugins/all_changes/SubProjectChangesAggregator.java index 5ad2097..5aa93c8 100644 --- a/src/main/java/org/jenkinsci/plugins/all_changes/SubProjectChangesAggregator.java +++ b/src/main/java/org/jenkinsci/plugins/all_changes/SubProjectChangesAggregator.java @@ -47,7 +47,8 @@ * @author wolfs */ @Extension -public class SubProjectChangesAggregator extends ChangesAggregator { +public class SubProjectChangesAggregator extends ChangesAggregator { + @Override public Collection aggregateBuildsWithChanges(AbstractBuild build) { Plugin parameterizedTrigger = Util.getInstance().getPlugin("parameterized-trigger"); diff --git a/src/main/resources/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction/main.groovy b/src/main/resources/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction/main.groovy index dba20bc..198fe06 100644 --- a/src/main/resources/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction/main.groovy +++ b/src/main/resources/org/jenkinsci/plugins/all_changes/AllChangesWorkflowAction/main.groovy @@ -24,6 +24,7 @@ import com.google.common.collect.Multimap import hudson.Functions +import hudson.model.Run import hudson.scm.ChangeLogSet import org.jenkinsci.plugins.workflow.job.WorkflowRun import org.jvnet.localizer.LocaleProvider @@ -61,7 +62,7 @@ private showChanges(Collection builds) { def changedBuildCount = 1; boolean hadChanges = false; for (WorkflowRun build in builds) { - Multimap changes = my.getAllChanges(build); + Multimap changes = my.getAllChanges(build); if (changes.empty) { continue } @@ -92,10 +93,10 @@ private showChanges(Collection builds) { } } -private def showEntry(entry, WorkflowRun build, Collection builds) { +private def showEntry(entry, WorkflowRun build, Collection builds) { showChangeSet(entry) boolean firstDrawn = false - for (WorkflowRun b in builds) { + for (Run b in builds) { if (b != build) { if (!firstDrawn) { text(" (") diff --git a/src/test/java/org/jenkinsci/plugins/all_changes/AllChangesWorkflowActionTest.java b/src/test/java/org/jenkinsci/plugins/all_changes/AllChangesWorkflowActionTest.java new file mode 100644 index 0000000..a56aa6d --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/all_changes/AllChangesWorkflowActionTest.java @@ -0,0 +1,84 @@ +/* + * The MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.jenkinsci.plugins.all_changes; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import hudson.model.AbstractBuild; +import hudson.model.Run; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.util.Set; + +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({ + WorkflowRun.class +}) +public class AllChangesWorkflowActionTest { + + @Test + public void getContributingBuildsShouldWorkTransitively() throws Exception { + + DependencyChangesAggregator aggregatorMock = mock(DependencyChangesAggregator.class); + WorkflowRun build = PowerMockito.mock(WorkflowRun.class); + AbstractBuild build2 = mock(AbstractBuild.class); + AbstractBuild build3 = mock(AbstractBuild.class); + when(aggregatorMock.aggregateBuildsWithChanges(build)).thenReturn(ImmutableList.of(build2)); + when(aggregatorMock.aggregateBuildsWithChanges(build2)).thenReturn(ImmutableList.of(build3)); + + AllChangesWorkflowAction changesAction = new AllChangesWorkflowAction(null); + changesAction.setAggregator(aggregatorMock); + + Set foundBuilds = changesAction.getContributingBuilds(build); + + assertTrue(foundBuilds.equals(ImmutableSet.of(build, build2, build3))); + } + + @Test + public void getContributingBuildsShouldWorkHandleCycles() throws Exception { + + DependencyChangesAggregator aggregatorMock = mock(DependencyChangesAggregator.class); + WorkflowRun build = PowerMockito.mock(WorkflowRun.class); + AbstractBuild build2 = mock(AbstractBuild.class); + AbstractBuild build3 = mock(AbstractBuild.class); + when(aggregatorMock.aggregateBuildsWithChanges(build)).thenReturn(ImmutableList.of(build2)); + when(aggregatorMock.aggregateBuildsWithChanges(build2)).thenReturn(ImmutableList.of(build3)); + when(aggregatorMock.aggregateBuildsWithChanges(build3)).thenReturn(ImmutableList.of(build)); + + AllChangesWorkflowAction changesAction = new AllChangesWorkflowAction(null); + changesAction.setAggregator(aggregatorMock); + + Set foundBuilds = changesAction.getContributingBuilds(build); + + assertTrue(foundBuilds.equals(ImmutableSet.of(build, build2, build3))); + } +} diff --git a/src/test/java/org/jenkinsci/plugins/all_changes/DependencyChangesAggregatorTest.java b/src/test/java/org/jenkinsci/plugins/all_changes/DependencyChangesAggregatorTest.java new file mode 100644 index 0000000..616d61e --- /dev/null +++ b/src/test/java/org/jenkinsci/plugins/all_changes/DependencyChangesAggregatorTest.java @@ -0,0 +1,172 @@ +/* + * The MIT License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package org.jenkinsci.plugins.all_changes; + +import hudson.FilePath; +import hudson.model.*; +import hudson.tasks.Fingerprinter; +import org.hamcrest.Matchers; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.jvnet.hudson.test.CreateFileBuilder; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.WithoutJenkins; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import java.io.*; +import java.util.*; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(PowerMockRunner.class) +@PrepareForTest({ + Fingerprinter.FingerprintAction.class +}) +// needed to prevent PowerMock from changing the crypto things +// (see "Troubleshooting" in https://wiki.jenkins.io/display/JENKINS/Mocking+in+Unit+Tests) +@PowerMockIgnore({"javax.crypto.*" }) +public class DependencyChangesAggregatorTest { + + @Rule + public JenkinsRule jenkins = new JenkinsRule(); + + private BuildListener testBuildListener; + private ByteArrayOutputStream out; + + @Before + public void setup() { + out = new ByteArrayOutputStream(); + testBuildListener = mock(BuildListener.class); + when(testBuildListener.getLogger()).thenReturn(new PrintStream(out)); + } + + @Test + public void returnsAllChangedDependencyBuilds() throws Exception { + + FreeStyleProject proj1 = jenkins.createFreeStyleProject("proj1"); + FreeStyleProject proj2 = jenkins.createFreeStyleProject("proj2"); + FreeStyleProject proj3 = jenkins.createFreeStyleProject("proj3"); + FreeStyleProject proj4 = jenkins.createFreeStyleProject("proj4"); + + jenkins.buildAndAssertSuccess(proj1); + createFileWithRandomString(proj1.getLastBuild(), "file1"); + fingerprintFile(proj1.getLastBuild(), proj1.getLastBuild().getWorkspace(), "file1"); + + jenkins.buildAndAssertSuccess(proj2); + createFileWithRandomString(proj2.getLastBuild(), "file2"); + fingerprintFile(proj2.getLastBuild(), proj2.getLastBuild().getWorkspace(), "file2"); + + jenkins.buildAndAssertSuccess(proj3); + createFileWithRandomString(proj3.getBuildByNumber(1), "file3"); + fingerprintFile(proj3.getLastBuild(), proj3.getLastBuild().getWorkspace(), "file3"); + + jenkins.buildAndAssertSuccess(proj4); + fingerprintFile(proj4.getLastBuild(), proj1.getBuildByNumber(1).getWorkspace(), "file1"); + fingerprintFile(proj4.getLastBuild(), proj2.getBuildByNumber(1).getWorkspace(), "file2"); + fingerprintFile(proj4.getLastBuild(), proj3.getBuildByNumber(1).getWorkspace(), "file3"); + + jenkins.buildAndAssertSuccess(proj1); + createFileWithRandomString(proj1.getLastBuild(), "file1"); + fingerprintFile(proj1.getLastBuild(), proj1.getLastBuild().getWorkspace(), "file1"); + + jenkins.buildAndAssertSuccess(proj1); + createFileWithRandomString(proj1.getLastBuild(), "file1"); + fingerprintFile(proj1.getLastBuild(), proj1.getLastBuild().getWorkspace(), "file1"); + + jenkins.buildAndAssertSuccess(proj2); + createFileWithRandomString(proj2.getLastBuild(), "file2"); + fingerprintFile(proj2.getLastBuild(), proj2.getLastBuild().getWorkspace(), "file2"); + + jenkins.buildAndAssertSuccess(proj4); + fingerprintFile(proj4.getLastBuild(), proj1.getBuildByNumber(2).getWorkspace(), "file1"); + fingerprintFile(proj4.getLastBuild(), proj1.getBuildByNumber(3).getWorkspace(), "file1"); + fingerprintFile(proj4.getLastBuild(), proj2.getBuildByNumber(2).getWorkspace(), "file2"); + + Collection res = (new DependencyChangesAggregator()).aggregateBuildsWithChanges(proj4.getBuildByNumber(2)); + + assertThat(res, Matchers.containsInAnyOrder( + proj1.getBuildByNumber(2), + proj1.getBuildByNumber(3), + proj2.getBuildByNumber(2))); + } + + @Test + @WithoutJenkins + public void returnsEmptyCollectionIfBuildHasNoPreviousBuild() { + + Run build = mock(Run.class); + when(build.getPreviousBuild()).thenReturn(null); + + Collection res = new DependencyChangesAggregator().aggregateBuildsWithChanges(build); + + assertThat(res, Matchers.empty()); + } + + @Test + @WithoutJenkins + public void returnsEmptyCollectionIfBuildHasNoFingerprintAction() { + + Run build1 = mock(Run.class); + when(build1.getAction(Fingerprinter.FingerprintAction.class)).thenReturn(mock(Fingerprinter.FingerprintAction.class)); + + Run build2 = mock(Run.class); + when(build2.getPreviousBuild()).thenReturn(build1); + when(build2.getAction(Fingerprinter.FingerprintAction.class)).thenReturn(null); + + Collection res = new DependencyChangesAggregator().aggregateBuildsWithChanges(build2); + + assertThat(res, Matchers.empty()); + } + + @Test + @WithoutJenkins + public void returnsEmptyCollectionIfPreviousBuildHasNoFingerprintAction() { + + Run build1 = mock(Run.class); + when(build1.getAction(Fingerprinter.FingerprintAction.class)).thenReturn(null); + + Run build2 = mock(Run.class); + when(build2.getPreviousBuild()).thenReturn(build1); + when(build2.getAction(Fingerprinter.FingerprintAction.class)).thenReturn(mock(Fingerprinter.FingerprintAction.class)); + + Collection res = new DependencyChangesAggregator().aggregateBuildsWithChanges(build2); + + assertThat(res, Matchers.empty()); + } + + private void createFileWithRandomString(AbstractBuild build, String filename) throws IOException, InterruptedException { + CreateFileBuilder fileBuilder = new CreateFileBuilder(filename, UUID.randomUUID().toString()); + fileBuilder.perform(build, null, testBuildListener); + } + + private void fingerprintFile(Run build, FilePath directory, String filename) throws InterruptedException { + Fingerprinter fingerprinter1 = new Fingerprinter(filename); + fingerprinter1.perform(build, directory, null, testBuildListener); + } +}