From 7fdf067aaddcf69f1a02653d34ed3da610e9a3fb Mon Sep 17 00:00:00 2001 From: Basil Crow Date: Mon, 25 Jul 2022 07:27:28 -0700 Subject: [PATCH] [JENKINS-45047] Support for plugin-to-plugin integration tests (#336) Co-authored-by: Jesse Glick --- pom.xml | 16 + .../invoker.properties | 2 + .../pom.xml | 25 + .../tools/hpi/its/HelloWorldBuilder.java | 144 ++++ .../verify.groovy | 3 + .../invoker.properties | 1 + .../override-test-dependencies-smokes/pom.xml | 68 ++ .../src/main/resources/index.jelly | 2 + .../src/test/java/test/SampleTest.java | 42 + .../verify.groovy | 3 + .../invoker.properties | 1 + .../pom.xml | 68 ++ .../src/main/resources/index.jelly | 2 + .../src/test/java/test/SampleTest.java | 42 + .../verify.groovy | 3 + .../maven/plugins/hpi/MavenArtifact.java | 8 + .../maven/plugins/hpi/TestDependencyMojo.java | 794 +++++++++++++++++- 17 files changed, 1215 insertions(+), 9 deletions(-) create mode 100644 src/it/override-test-dependencies-release-failure/invoker.properties create mode 100644 src/it/override-test-dependencies-release-failure/pom.xml create mode 100644 src/it/override-test-dependencies-release-failure/src/main/java/org/jenkinsci/tools/hpi/its/HelloWorldBuilder.java create mode 100644 src/it/override-test-dependencies-release-failure/verify.groovy create mode 100644 src/it/override-test-dependencies-smokes/invoker.properties create mode 100644 src/it/override-test-dependencies-smokes/pom.xml create mode 100644 src/it/override-test-dependencies-smokes/src/main/resources/index.jelly create mode 100644 src/it/override-test-dependencies-smokes/src/test/java/test/SampleTest.java create mode 100644 src/it/override-test-dependencies-smokes/verify.groovy create mode 100644 src/it/override-test-dependencies-useUpperBounds/invoker.properties create mode 100644 src/it/override-test-dependencies-useUpperBounds/pom.xml create mode 100644 src/it/override-test-dependencies-useUpperBounds/src/main/resources/index.jelly create mode 100644 src/it/override-test-dependencies-useUpperBounds/src/test/java/test/SampleTest.java create mode 100644 src/it/override-test-dependencies-useUpperBounds/verify.groovy diff --git a/pom.xml b/pom.xml index 648fe861f5..041b553991 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,7 @@ 3.8.6 3.6.4 2.1.1 + 1.1.0 ${project.basedir}/src/spotbugs/spotbugs-excludes.xml @@ -92,6 +93,16 @@ aether-api ${aether.version} + + org.eclipse.aether + aether-impl + ${aether.version} + + + org.eclipse.aether + aether-spi + ${aether.version} + org.eclipse.aether aether-util @@ -153,6 +164,11 @@ maven-artifact-transfer 0.13.1 + + org.apache.maven.shared + maven-dependency-tree + 3.1.0 + org.codehaus.plexus plexus-component-annotations diff --git a/src/it/override-test-dependencies-release-failure/invoker.properties b/src/it/override-test-dependencies-release-failure/invoker.properties new file mode 100644 index 0000000000..536d500ccb --- /dev/null +++ b/src/it/override-test-dependencies-release-failure/invoker.properties @@ -0,0 +1,2 @@ +invoker.goals=-ntp -DoverrideVersions=org.jenkins-ci.plugins.workflow:workflow-step-api:2.11 deploy +invoker.buildResult = failure diff --git a/src/it/override-test-dependencies-release-failure/pom.xml b/src/it/override-test-dependencies-release-failure/pom.xml new file mode 100644 index 0000000000..a6f7654a7d --- /dev/null +++ b/src/it/override-test-dependencies-release-failure/pom.xml @@ -0,0 +1,25 @@ + + + 4.0.0 + + + org.jenkins-ci.plugins + plugin + 4.40 + + + check-core-version-failure + 1.0-SNAPSHOT + hpi + + 2.249.1 + @project.version@ + + + + org.jenkins-ci.plugins + jackson2-api + 2.13.2-260.v43d711474c77 + + + diff --git a/src/it/override-test-dependencies-release-failure/src/main/java/org/jenkinsci/tools/hpi/its/HelloWorldBuilder.java b/src/it/override-test-dependencies-release-failure/src/main/java/org/jenkinsci/tools/hpi/its/HelloWorldBuilder.java new file mode 100644 index 0000000000..75555635d5 --- /dev/null +++ b/src/it/override-test-dependencies-release-failure/src/main/java/org/jenkinsci/tools/hpi/its/HelloWorldBuilder.java @@ -0,0 +1,144 @@ +package org.jenkinsci.tools.hpi.its; + +import com.fasterxml.jackson.databind.ObjectMapper; +import hudson.Launcher; +import hudson.Extension; +import hudson.util.FormValidation; +import hudson.model.AbstractBuild; +import hudson.model.BuildListener; +import hudson.model.AbstractProject; +import hudson.tasks.Builder; +import hudson.tasks.BuildStepDescriptor; +import java.lang.RuntimeException; +import net.sf.json.JSONObject; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.StaplerRequest; +import org.kohsuke.stapler.QueryParameter; + +import javax.servlet.ServletException; +import java.io.IOException; + +/** + * Sample {@link Builder}. + * + *

+ * When the user configures the project and enables this builder, + * {@link org.jenkins.HelloWorldBuilder.DescriptorImpl#newInstance(StaplerRequest)} is invoked + * and a new {@link org.jenkins.HelloWorldBuilder} is created. The created + * instance is persisted to the project configuration XML by using + * XStream, so this allows you to use instance fields (like {@link #name}) + * to remember the configuration. + * + *

+ * When a build is performed, the {@link #perform(AbstractBuild, Launcher, BuildListener)} method + * will be invoked. + * + * @author Kohsuke Kawaguchi + */ +public class HelloWorldBuilder extends Builder { + + private final String name; + + // Fields in config.jelly must match the parameter names in the "DataBoundConstructor" + @DataBoundConstructor + public HelloWorldBuilder(String name) { + this.name = name; + } + + /** + * We'll use this from the {@code config.jelly}. + */ + public String getName() { + return name; + } + + @Override + public boolean perform(AbstractBuild build, Launcher launcher, BuildListener listener) { + // this is where you 'build' the project + // since this is a dummy, we just say 'hello world' and call that a build + + // this also shows how you can consult the global configuration of the builder + if (getDescriptor().useFrench()) + listener.getLogger().println("Bonjour, " + name + "!"); + else + listener.getLogger().println("Hello, " + name + "!"); + return true; + } + + // overrided for better type safety. + // if your plugin doesn't really define any property on Descriptor, + // you don't have to do this. + @Override + public DescriptorImpl getDescriptor() { + return (DescriptorImpl) super.getDescriptor(); + } + + /** + * Descriptor for {@link org.jenkins.HelloWorldBuilder}. Used as a singleton. + * The class is marked as public so that it can be accessed from views. + * + *

+ * See {@code views/hudson/plugins/hello_world/HelloWorldBuilder/*.jelly} + * for the actual HTML fragment for the configuration screen. + */ + @Extension // this marker indicates Hudson that this is an implementation of an extension point. + public static final class DescriptorImpl extends BuildStepDescriptor { + /** + * To persist global configuration information, + * simply store it in a field and call save(). + * + *

+ * If you don't want fields to be persisted, use {@code transient}. + */ + private boolean useFrench; + + /** + * Performs on-the-fly validation of the form field 'name'. + * + * @param value This parameter receives the value that the user has typed. + * @return Indicates the outcome of the validation. This is sent to the browser. + */ + public FormValidation doCheckName(@QueryParameter String value) throws IOException, ServletException { + if (value.length() == 0) + return FormValidation.error("Please set a name"); + if (value.length() < 4) + return FormValidation.warning("Isn't the name too short?"); + return FormValidation.ok(); + } + + public boolean isApplicable(Class aClass) { + // indicates that this builder can be used with all kinds of project types + return true; + } + + /** + * This human readable name is used in the configuration screen. + */ + public String getDisplayName() { + try { + return new ObjectMapper().writeValueAsString("Say hello world"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Override + public boolean configure(StaplerRequest req, JSONObject formData) throws FormException { + // To persist global configuration information, + // set that to properties and call save(). + useFrench = formData.getBoolean("useFrench"); + // ^Can also use req.bindJSON(this, formData); + // (easier when there are many fields; need set* methods for this, like setUseFrench) + save(); + return super.configure(req, formData); + } + + /** + * This method returns true if the global configuration says we should speak French. + */ + public boolean useFrench() { + return useFrench; + } + } +} + diff --git a/src/it/override-test-dependencies-release-failure/verify.groovy b/src/it/override-test-dependencies-release-failure/verify.groovy new file mode 100644 index 0000000000..72affbede7 --- /dev/null +++ b/src/it/override-test-dependencies-release-failure/verify.groovy @@ -0,0 +1,3 @@ +assert new File(basedir, 'build.log').getText('UTF-8').contains('Cannot override dependencies when doing a release') + +return true diff --git a/src/it/override-test-dependencies-smokes/invoker.properties b/src/it/override-test-dependencies-smokes/invoker.properties new file mode 100644 index 0000000000..384bd1fafb --- /dev/null +++ b/src/it/override-test-dependencies-smokes/invoker.properties @@ -0,0 +1 @@ +invoker.goals=-ntp test diff --git a/src/it/override-test-dependencies-smokes/pom.xml b/src/it/override-test-dependencies-smokes/pom.xml new file mode 100644 index 0000000000..ca03a5e2ab --- /dev/null +++ b/src/it/override-test-dependencies-smokes/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + org.jenkins-ci.plugins + plugin + 4.40 + + + org.jenkins-ci.tools.hpi.its + override-test-dependencies-smokes + 1.0-SNAPSHOT + hpi + + 2.249 + @project.version@ + + org.jenkins-ci.plugins.workflow:workflow-step-api:2.11,org.jenkins-ci.plugins.workflow:workflow-api:2.17,org.jenkins-ci.plugins.workflow:workflow-cps:2.32 + SampleTest + false + 0 + 2.9 + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + org.jenkins-ci.plugins + structs + 1.6 + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + tests + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + 2.30 + test + + + antlr + antlr + + + + + diff --git a/src/it/override-test-dependencies-smokes/src/main/resources/index.jelly b/src/it/override-test-dependencies-smokes/src/main/resources/index.jelly new file mode 100644 index 0000000000..2f655e510a --- /dev/null +++ b/src/it/override-test-dependencies-smokes/src/main/resources/index.jelly @@ -0,0 +1,2 @@ + +

diff --git a/src/it/override-test-dependencies-smokes/src/test/java/test/SampleTest.java b/src/it/override-test-dependencies-smokes/src/test/java/test/SampleTest.java new file mode 100644 index 0000000000..a0a94d0658 --- /dev/null +++ b/src/it/override-test-dependencies-smokes/src/test/java/test/SampleTest.java @@ -0,0 +1,42 @@ +package test; + +import com.google.common.collect.ImmutableMap; +import hudson.remoting.Which; +import java.io.InputStream; +import java.net.URL; +import java.util.Enumeration; +import java.util.Map; +import java.util.jar.Manifest; +import org.jenkinsci.plugins.workflow.steps.StepConfigTester; +import org.junit.Test; +import static org.junit.Assert.*; +import org.junit.Rule; +import org.jvnet.hudson.test.JenkinsRule; + +public class SampleTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Test + public void smokes() throws Exception { + Map expectedVersions = ImmutableMap.of("workflow-step-api", "2.11", "workflow-api", "2.17", "workflow-cps", "2.32"); + Enumeration manifests = SampleTest.class.getClassLoader().getResources("META-INF/MANIFEST.MF"); + while (manifests.hasMoreElements()) { + URL url = manifests.nextElement(); + try (InputStream is = url.openStream()) { + Manifest mf = new Manifest(is); + String pluginName = mf.getMainAttributes().getValue("Short-Name"); + String expectedVersion = expectedVersions.get(pluginName); + if (expectedVersion != null) { + assertEquals("wrong version for " + pluginName + " as classpath entry", expectedVersion, mf.getMainAttributes().getValue("Plugin-Version")); + } + } + } + for (Map.Entry entry : expectedVersions.entrySet()) { + assertEquals("wrong version for " + entry.getKey() + " as plugin", entry.getValue(), r.jenkins.pluginManager.getPlugin(entry.getKey()).getVersion()); + } + assertEquals("workflow-step-api-2.11-tests.jar", Which.jarFile(StepConfigTester.class).getName()); + } + +} diff --git a/src/it/override-test-dependencies-smokes/verify.groovy b/src/it/override-test-dependencies-smokes/verify.groovy new file mode 100644 index 0000000000..a4b7f510d8 --- /dev/null +++ b/src/it/override-test-dependencies-smokes/verify.groovy @@ -0,0 +1,3 @@ +def log = new File(basedir, 'build.log').text +// TODO add anything needed, or delete this file +true \ No newline at end of file diff --git a/src/it/override-test-dependencies-useUpperBounds/invoker.properties b/src/it/override-test-dependencies-useUpperBounds/invoker.properties new file mode 100644 index 0000000000..384bd1fafb --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/invoker.properties @@ -0,0 +1 @@ +invoker.goals=-ntp test diff --git a/src/it/override-test-dependencies-useUpperBounds/pom.xml b/src/it/override-test-dependencies-useUpperBounds/pom.xml new file mode 100644 index 0000000000..fb9708b866 --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/pom.xml @@ -0,0 +1,68 @@ + + + 4.0.0 + + org.jenkins-ci.plugins + plugin + 4.40 + + + org.jenkins-ci.tools.hpi.its + override-test-dependencies-useUpperBounds + 1.0-SNAPSHOT + hpi + + 2.249 + @project.version@ + org.jenkins-ci.plugins.workflow:workflow-cps:2.33 + true + SampleTest + false + 0 + 2.9 + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + org.jenkins-ci.plugins + structs + 1.6 + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + + + org.jenkins-ci.plugins.workflow + workflow-step-api + ${workflow-step-api-plugin.version} + tests + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + 2.30 + test + + + antlr + antlr + + + + + diff --git a/src/it/override-test-dependencies-useUpperBounds/src/main/resources/index.jelly b/src/it/override-test-dependencies-useUpperBounds/src/main/resources/index.jelly new file mode 100644 index 0000000000..2f655e510a --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/src/main/resources/index.jelly @@ -0,0 +1,2 @@ + +
diff --git a/src/it/override-test-dependencies-useUpperBounds/src/test/java/test/SampleTest.java b/src/it/override-test-dependencies-useUpperBounds/src/test/java/test/SampleTest.java new file mode 100644 index 0000000000..24ad880e7b --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/src/test/java/test/SampleTest.java @@ -0,0 +1,42 @@ +package test; + +import com.google.common.collect.ImmutableMap; +import hudson.remoting.Which; +import java.io.InputStream; +import java.net.URL; +import java.util.Enumeration; +import java.util.Map; +import java.util.jar.Manifest; +import org.jenkinsci.plugins.workflow.steps.StepConfigTester; +import org.junit.Test; +import static org.junit.Assert.*; +import org.junit.Rule; +import org.jvnet.hudson.test.JenkinsRule; + +public class SampleTest { + + @Rule + public JenkinsRule r = new JenkinsRule(); + + @Test + public void smokes() throws Exception { + Map expectedVersions = ImmutableMap.of("structs", "1.7", "workflow-step-api", "2.10", "workflow-api", "2.16", "workflow-cps", "2.33"); + Enumeration manifests = SampleTest.class.getClassLoader().getResources("META-INF/MANIFEST.MF"); + while (manifests.hasMoreElements()) { + URL url = manifests.nextElement(); + try (InputStream is = url.openStream()) { + Manifest mf = new Manifest(is); + String pluginName = mf.getMainAttributes().getValue("Short-Name"); + String expectedVersion = expectedVersions.get(pluginName); + if (expectedVersion != null) { + assertEquals("wrong version for " + pluginName + " as classpath entry", expectedVersion, mf.getMainAttributes().getValue("Plugin-Version")); + } + } + } + for (Map.Entry entry : expectedVersions.entrySet()) { + assertEquals("wrong version for " + entry.getKey() + " as plugin", entry.getValue(), r.jenkins.pluginManager.getPlugin(entry.getKey()).getVersion()); + } + assertEquals("workflow-step-api-2.10-tests.jar", Which.jarFile(StepConfigTester.class).getName()); + } + +} diff --git a/src/it/override-test-dependencies-useUpperBounds/verify.groovy b/src/it/override-test-dependencies-useUpperBounds/verify.groovy new file mode 100644 index 0000000000..a4b7f510d8 --- /dev/null +++ b/src/it/override-test-dependencies-useUpperBounds/verify.groovy @@ -0,0 +1,3 @@ +def log = new File(basedir, 'build.log').text +// TODO add anything needed, or delete this file +true \ No newline at end of file diff --git a/src/main/java/org/jenkinsci/maven/plugins/hpi/MavenArtifact.java b/src/main/java/org/jenkinsci/maven/plugins/hpi/MavenArtifact.java index 71e81cf8d0..215ee4dab4 100644 --- a/src/main/java/org/jenkinsci/maven/plugins/hpi/MavenArtifact.java +++ b/src/main/java/org/jenkinsci/maven/plugins/hpi/MavenArtifact.java @@ -3,6 +3,7 @@ import hudson.util.VersionNumber; import org.apache.maven.artifact.Artifact; import org.apache.maven.artifact.factory.ArtifactFactory; +import org.apache.maven.artifact.handler.ArtifactHandler; import org.apache.maven.artifact.versioning.ArtifactVersion; import org.apache.maven.artifact.versioning.OverConstrainedVersionException; import org.apache.maven.execution.MavenSession; @@ -182,6 +183,13 @@ public String getClassifier() { return artifact.getClassifier(); } + /** + * Get the artifact handler. + */ + public ArtifactHandler getArtifactHandler() { + return artifact.getArtifactHandler(); + } + /** For a plugin artifact, unlike {@link #getArtifactId} this parses the plugin manifest. */ public String getActualArtifactId() throws IOException { File file = getFile(); diff --git a/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java b/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java index b928d9b0ae..ef9bd7a6d1 100644 --- a/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java +++ b/src/main/java/org/jenkinsci/maven/plugins/hpi/TestDependencyMojo.java @@ -1,18 +1,70 @@ package org.jenkinsci.maven.plugins.hpi; -import org.apache.commons.io.FileUtils; -import org.apache.maven.plugin.MojoExecutionException; -import org.apache.maven.plugin.MojoFailureException; -import org.apache.maven.plugins.annotations.Mojo; -import org.apache.maven.plugins.annotations.ResolutionScope; - +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStreamWriter; import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.NavigableSet; +import java.util.Properties; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarInputStream; +import java.util.jar.Manifest; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.apache.commons.io.FileUtils; +import org.apache.maven.RepositoryUtils; +import org.apache.maven.artifact.Artifact; +import org.apache.maven.artifact.resolver.filter.ArtifactFilter; +import org.apache.maven.artifact.versioning.ArtifactVersion; +import org.apache.maven.artifact.versioning.ComparableVersion; +import org.apache.maven.artifact.versioning.DefaultArtifactVersion; +import org.apache.maven.artifact.versioning.OverConstrainedVersionException; +import org.apache.maven.execution.MavenSession; +import org.apache.maven.lifecycle.internal.LifecycleDependencyResolver; +import org.apache.maven.model.Dependency; +import org.apache.maven.model.DependencyManagement; +import org.apache.maven.plugin.BuildPluginManager; +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.plugins.annotations.Component; +import org.apache.maven.plugins.annotations.Mojo; +import org.apache.maven.plugins.annotations.Parameter; +import org.apache.maven.plugins.annotations.ResolutionScope; +import org.apache.maven.project.DefaultDependencyResolutionRequest; +import org.apache.maven.project.DefaultProjectBuildingRequest; +import org.apache.maven.project.DependencyResolutionException; +import org.apache.maven.project.DependencyResolutionRequest; +import org.apache.maven.project.DependencyResolutionResult; +import org.apache.maven.project.MavenProject; +import org.apache.maven.project.ProjectBuildingRequest; +import org.apache.maven.project.ProjectDependenciesResolver; +import org.apache.maven.shared.dependency.graph.DependencyCollectorBuilder; +import org.apache.maven.shared.dependency.graph.DependencyCollectorBuilderException; +import org.apache.maven.shared.dependency.graph.DependencyNode; +import org.apache.maven.shared.dependency.graph.traversal.DependencyNodeVisitor; +import org.twdata.maven.mojoexecutor.MojoExecutor; /** * Places test-dependency plugins into somewhere the test harness can pick up. @@ -20,12 +72,235 @@ *

* See {@code TestPluginManager.loadBundledPlugins()} where the test harness uses it. * - * @author Kohsuke Kawaguchi + *

Additionally, it may adjust the classpath for {@code surefire:test} to run tests against + * different versions of various dependencies than what was configured in the POM. */ @Mojo(name="resolve-test-dependencies", requiresDependencyResolution = ResolutionScope.TEST) +@SuppressFBWarnings(value = "REDOS", justification = "trusted code") public class TestDependencyMojo extends AbstractHpiMojo { + + private static final Pattern CORE_REGEX = Pattern.compile("WEB-INF/lib/jenkins-core-([0-9.]+(?:-[0-9a-f.]+)*(?:-(?i)([a-z]+)(-)?([0-9a-f.]+)?)?(?:-(?i)([a-z]+)(-)?([0-9a-f_.]+)?)?(?:-SNAPSHOT)?)[.]jar"); + private static final Pattern PLUGIN_REGEX = Pattern.compile("WEB-INF/plugins/([^/.]+)[.][hj]pi"); + private static final Pattern OVERRIDE_REGEX = Pattern.compile("([^:]+:[^:]+):([^:]+)"); + + @Component private BuildPluginManager pluginManager; + + @Component private DependencyCollectorBuilder dependencyCollectorBuilder; + + @Component private ProjectDependenciesResolver dependenciesResolver; + + /** + * List of dependency version overrides in the form {@code groupId:artifactId:version} to apply + * during testing. Must correspond to dependencies already present in the project model or their + * transitive dependencies. + */ + @Parameter(property = "overrideVersions") + private List overrideVersions; + + /** + * Path to a Jenkins WAR file with bundled plugins to apply during testing. Dependencies already + * present in the project model or their transitive dependencies will be updated to the versions + * in the WAR. Dependencies not already present in the project model will be added to the + * project model. May be combined with {@code overrideVersions} so long as the results do not + * conflict. The version of the WAR must be identical to {@code jenkins.version}. + */ + @Parameter(property = "overrideWar") + private File overrideWar; + + /** + * Whether to update all transitive dependencies to the upper bounds. Effectively causes the + * same behavior as the {@code requireUpperBoundDeps} Enforcer rule would, if the specified + * dependencies were to be written to the POM. Intended for use in conjunction with {@link + * #overrideVersions} or {@link #overrideWar}. + */ + @Parameter(property = "useUpperBounds") + private boolean useUpperBounds; + + /** + * List of exclusions to upper bound updates in the form {@code groupId:artifactId}. + * Must not be provided when {@link #useUpperBounds} is false. + */ + @Parameter(property = "upperBoundsExcludes") + private List upperBoundsExcludes; + @Override - public void execute() throws MojoExecutionException, MojoFailureException { + public void execute() throws MojoExecutionException { + Map overrides = overrideVersions != null ? parseOverrides(overrideVersions) : Collections.emptyMap(); + if (!overrides.isEmpty()) { + getLog().info(String.format("Applying %d overrides.", overrides.size())); + } + if (overrides.containsKey(String.format("%s:%s", project.getGroupId(), project.getArtifactId()))) { + throw new MojoExecutionException("Cannot override self"); + } + + Map bundledPlugins = overrideWar != null ? scanWar(overrideWar, session, project) : Collections.emptyMap(); + if (!bundledPlugins.isEmpty()) { + getLog().info(String.format("Scanned contents of %s with %d bundled plugins", overrideWar, bundledPlugins.size())); + } + + // Deal with conflicts in user-provided input. + Set intersection = new HashSet<>(bundledPlugins.keySet()); + intersection.retainAll(overrides.keySet()); + for (String override : intersection) { + if (bundledPlugins.get(override).equals(overrides.get(override))) { + /* + * Not really a conflict since the versions are the same. Remove it from one of the + * two lists to simplify the implementation later. We pick the former since the + * semantics for the latter are looser. + */ + overrides.remove(override); + } else { + throw new MojoExecutionException(String.format( + "Failed to override %s: conflict between %s in overrideVersions and %s in overrideWar", + override, overrides.get(override), bundledPlugins.get(override))); + } + } + + // The effective artifacts to be used when building the plugin index and test classpath. + Set effectiveArtifacts; + + // Track changes to the classpath when the user has overridden dependency versions. + Map additions = new HashMap<>(); + Map deletions = new HashMap<>(); + Map updates = new HashMap<>(); + + if (overrides.isEmpty() && overrideWar == null) { + effectiveArtifacts = getProjectArtfacts(); + } else { + // Under no circumstances should this code ever be executed when performing a release. + for (String goal : session.getGoals()) { + if (goal.contains("install") || goal.contains("deploy")) { + throw new MojoExecutionException("Cannot override dependencies when doing a release"); + } + } + + // Create a shadow project for dependency analysis. + MavenProject shadow = project.clone(); + + // Stash the original resolution for use later. + Map originalResolution = new HashMap<>(); + for (Artifact artifact : shadow.getArtifacts()) { + originalResolution.put(toKey(artifact), artifact.getVersion()); + } + + // First pass: apply the overrides specified by the user. + applyOverrides(overrides, bundledPlugins, false, shadow, getLog()); + + if (useUpperBounds) { + boolean converged = false; + int i = 0; + + while (!converged) { + if (i++ > 10) { + throw new MojoExecutionException("Failed to iterate to convergence during upper bounds analysis"); + } + + /* + * Do upper bounds analysis. Upper bounds analysis consumes the model directly and + * not the resolution of that model, so it is fine to invoke it at this point with + * the model having been updated and the resolution having been cleared. + */ + DependencyNode node; + try { + ProjectBuildingRequest buildingRequest = new DefaultProjectBuildingRequest(session.getProjectBuildingRequest()); + buildingRequest.setProject(shadow); + ArtifactFilter filter = null; // Evaluate all scopes + node = dependencyCollectorBuilder.collectDependencyGraph(buildingRequest, filter); + } catch (DependencyCollectorBuilderException e) { + throw new MojoExecutionException("Failed to analyze dependency tree for useUpperBounds", e); + } + RequireUpperBoundDepsVisitor visitor = new RequireUpperBoundDepsVisitor(); + node.accept(visitor); + Map upperBounds = visitor.upperBounds(upperBoundsExcludes); + + if (upperBounds.isEmpty()) { + converged = true; + } else { + // Second pass: apply the results of the upper bounds analysis. + + /* + * applyOverrides depends on resolution, so resolve again between the first pass + * and the second. + */ + Set resolved = resolveDependencies(shadow); + shadow.setArtifacts(resolved); + + applyOverrides(upperBounds, Collections.emptyMap(), true, shadow, getLog()); + } + } + } else if (!upperBoundsExcludes.isEmpty()) { + throw new MojoExecutionException("Cannot provide upper bounds excludes when not using upper bounds"); + } + + /* + * At this point, the model has been updated as the user has requested. We now redo + * resolution and compare the new resolution to the original in order to account for + * updates to transitive dependencies that are not present in the model. Anything that + * was updated in the new resolution needs to be updated in the test classpath. Anything + * that was removed in the new resolution needs to be removed from the test classpath. + * Anything that was added in the new resolution needs to be added to the test + * classpath. + */ + Set resolved = resolveDependencies(shadow); + Map newResolution = new HashMap<>(); + Set self = new HashSet<>(); + for (Artifact artifact : resolved) { + if (artifact.getGroupId().equals(project.getGroupId()) && artifact.getArtifactId().equals(project.getArtifactId())) { + self.add(artifact); + } else { + newResolution.put(toKey(artifact), artifact.getVersion()); + } + } + resolved.removeAll(self); + effectiveArtifacts = wrap(new Artifacts(resolved)); + for (Map.Entry entry : newResolution.entrySet()) { + if (originalResolution.containsKey(entry.getKey())) { + // Present in both old and new resolution: check for update. + String originalVersion = originalResolution.get(entry.getKey()); + String newVersion = entry.getValue(); + /* + * We check that the new version is not equal to the original version rather + * than newer than the original version for the following reason. Suppose we + * depend on A:1.0 which depends on B:1.2. Now suppose a problem is discovered + * in B:1.2 that results in A:1.1 rolling back to B:1.1. We only ever directly + * depended on A:1.0, but now we override A:1.0 to A:1.1. B:1.2 was in our + * transitive tree before, but now for correctness we must change B from 1.2 to + * 1.1. + */ + if (!newVersion.equals(originalVersion)) { + updates.put(entry.getKey(), newVersion); + } + } else { + // Present in new resolution but not old: addition. + additions.put(entry.getKey(), entry.getValue()); + } + } + for (Map.Entry entry : originalResolution.entrySet()) { + if (!newResolution.containsKey(entry.getKey())) { + // Present in old resolution but not new: deletion. + deletions.put(entry.getKey(), entry.getValue()); + } + } + getLog().info("After resolving, additions: " + additions); + getLog().info("After resolving, deletions: " + deletions); + getLog().info("After resolving, updates: " + updates); + if (getLog().isDebugEnabled()) { + getLog().debug("New dependency tree:"); + MavenSession shadowSession = session.clone(); + shadowSession.setCurrentProject(shadow); + shadow.setArtifacts(resolved); + MojoExecutor.executeMojo( + MojoExecutor.plugin( + MojoExecutor.groupId("org.apache.maven.plugins"), + MojoExecutor.artifactId("maven-dependency-plugin")), + MojoExecutor.goal("tree"), + MojoExecutor.configuration( + MojoExecutor.element( + MojoExecutor.name("scope"), Artifact.SCOPE_TEST)), + MojoExecutor.executionEnvironment(shadow, shadowSession, pluginManager)); + } + } + File testDir = new File(project.getBuild().getTestOutputDirectory(), "test-dependencies"); try { Files.createDirectories(testDir.toPath()); @@ -34,7 +309,7 @@ public void execute() throws MojoExecutionException, MojoFailureException { } try (FileOutputStream fos = new FileOutputStream(new File(testDir, "index")); Writer w = new OutputStreamWriter(fos, StandardCharsets.UTF_8)) { - for (MavenArtifact a : getProjectArtfacts()) { + for (MavenArtifact a : effectiveArtifacts) { if (!a.isPluginBestEffort(getLog())) continue; @@ -52,5 +327,506 @@ public void execute() throws MojoExecutionException, MojoFailureException { } catch (IOException e) { throw new MojoExecutionException("Failed to copy dependency plugins",e); } + + if (!additions.isEmpty() || !deletions.isEmpty() || !updates.isEmpty()) { + List additionalClasspathElements = new LinkedList<>(); + NavigableMap includes = new TreeMap<>(); + includes.putAll(additions); + includes.putAll(updates); + for (Map.Entry entry : includes.entrySet()) { + String key = entry.getKey(); + String[] groupArt = key.split(":"); + String groupId = groupArt[0]; + String artifactId = groupArt[1]; + String version = entry.getValue(); + /* + * We cannot use MavenProject.getArtifactMap since we may have multiple dependencies + * of different classifiers. + */ + boolean found = false; + // Yeah, this is O(n²)... deal with it! + for (MavenArtifact a : effectiveArtifacts) { + if (!a.getGroupId().equals(groupId) || !a.getArtifactId().equals(artifactId)) { + continue; + } + if (!a.getVersion().equals(version)) { + throw new AssertionError("should never happen"); + } + found = true; + if (a.getArtifactHandler().isAddedToClasspath()) { + /* + * Everything is added to the test classpath, so there is no need to check + * scope. + */ + additionalClasspathElements.add(a.getFile().getAbsolutePath()); + } + } + if (!found) { + throw new MojoExecutionException("could not find dependency " + key); + } + } + + NavigableSet classpathDependencyExcludes = new TreeSet<>(); + classpathDependencyExcludes.addAll(deletions.keySet()); + classpathDependencyExcludes.addAll(updates.keySet()); + + Properties properties = project.getProperties(); + if (getLog().isDebugEnabled()) { + getLog().debug(String.format("Replacing POM-defined classpath elements %s with %s", classpathDependencyExcludes, additionalClasspathElements)); + } + // cf. http://maven.apache.org/surefire/maven-surefire-plugin/test-mojo.html + properties.setProperty("maven.test.additionalClasspath", String.join(",", additionalClasspathElements)); + properties.setProperty("maven.test.dependency.excludes", String.join(",", classpathDependencyExcludes)); + } + } + + /** + * Scan a WAR file, accumulating plugin information. + * + * @param war The WAR to scan. + * @return The bundled plugins in the WAR. + */ + private static Map scanWar(File war, MavenSession session, MavenProject project) throws MojoExecutionException { + Map overrides = new HashMap<>(); + try (JarFile jf = new JarFile(war)) { + Enumeration entries = jf.entries(); + String coreVersion = null; + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + Matcher m = CORE_REGEX.matcher(name); + if (m.matches()) { + if (coreVersion != null) { + throw new MojoExecutionException("More than 1 jenkins-core JAR in " + war); + } + coreVersion = m.group(1); + } + m = PLUGIN_REGEX.matcher(name); + if (m.matches()) { + try (InputStream is = jf.getInputStream(entry); JarInputStream jis = new JarInputStream(is)) { + Manifest manifest = jis.getManifest(); + String groupId = manifest.getMainAttributes().getValue("Group-Id"); + if (groupId == null) { + throw new IllegalArgumentException("Failed to determine group ID for " + name); + } + String artifactId = manifest.getMainAttributes().getValue("Short-Name"); + if (artifactId == null) { + throw new IllegalArgumentException("Failed to determine artifact ID for " + name); + } + String version = manifest.getMainAttributes().getValue("Plugin-Version"); + if (version == null) { + throw new IllegalArgumentException("Failed to determine version for " + name); + } + String key = String.format("%s:%s", groupId, artifactId); + String self = String.format("%s:%s", project.getGroupId(), project.getArtifactId()); + if (!key.equals(self)) { + overrides.put(key, version); + } + } + } + } + + /* + * It is tempting to try and avoid the requirement for jenkins.version here and simply + * override the core dependencies in the tree; however, this fails to take into account + * that core's provided dependencies are already being managed at their original + * versions. + */ + if (coreVersion == null) { + throw new MojoExecutionException("no jenkins-core.jar in " + war); + } + String jenkinsVersion = session.getSystemProperties().getProperty("jenkins.version"); + if (jenkinsVersion == null) { + jenkinsVersion = project.getProperties().getProperty("jenkins.version"); + } + if (jenkinsVersion == null) { + throw new MojoExecutionException("jenkins.version must be set when using overrideWar"); + } else if (!jenkinsVersion.equals(coreVersion)) { + throw new MojoExecutionException("jenkins.version must match the version specified by overrideWar: " + coreVersion); + } + } catch (IOException e) { + throw new MojoExecutionException("Failed to scan " + war, e); + } + return overrides; + } + + private static Map parseOverrides(List overrideVersions) throws MojoExecutionException { + Map overrides = new HashMap<>(); + for (String override : overrideVersions) { + Matcher m = OVERRIDE_REGEX.matcher(override); + if (!m.matches()) { + throw new MojoExecutionException("illegal override: " + override); + } + overrides.put(m.group(1), m.group(2)); + } + return overrides; + } + + /** + * Apply the overrides specified by the user or upper bounds analysis to the model (i.e., + * dependency management or dependencies) in the shadow project. This clears the existing + * resolution that was done because of the {@code @requiresDependencyResolution} Mojo attribute, + * as it is now invalid. It is possible to perform such a pass manually on a plugin and compare + * the results with this algorithm to verify that the logic in this method is correct. + */ + private static void applyOverrides( + Map overrides, + Map bundledPlugins, + boolean upperBounds, + MavenProject project, + Log log) + throws MojoExecutionException { + Set appliedOverrides = new HashSet<>(); + Set appliedBundledPlugins = new HashSet<>(); + + // Update existing dependency entries in the model. + for (Dependency dependency : project.getDependencies()) { + String key = toKey(dependency); + if (updateDependency(dependency, overrides, "direct dependency", log)) { + appliedOverrides.add(key); + } + if (updateDependency(dependency, bundledPlugins, "direct dependency", log)) { + appliedBundledPlugins.add(key); + } + } + + // Update existing dependency management entries in the model. + if (project.getDependencyManagement() != null) { + for (Dependency dependency : project.getDependencyManagement().getDependencies()) { + String key = toKey(dependency); + if (updateDependency(dependency, overrides, "dependency management entry", log)) { + appliedOverrides.add(key); + } + if (updateDependency(dependency, bundledPlugins, "dependency management entry", log)) { + appliedBundledPlugins.add(key); + } + } + } + + /* + * If an override was requested for a transitive dependency that is not in the model, add a + * dependency management entry to the model. + */ + Set unappliedOverrides = new HashSet<>(overrides.keySet()); + unappliedOverrides.removeAll(appliedOverrides); + Set overrideAdditions = new HashSet<>(); + for (Artifact artifact : project.getArtifacts()) { + String key = toKey(artifact); + if (unappliedOverrides.contains(key)) { + String version = overrides.get(key); + Dependency dependency = new Dependency(); + dependency.setGroupId(artifact.getGroupId()); + dependency.setArtifactId(artifact.getArtifactId()); + dependency.setVersion(version); + dependency.setScope(artifact.getScope()); + dependency.setType(artifact.getType()); + dependency.setClassifier(artifact.getClassifier()); + if (dependency.getGroupId().equals(project.getGroupId()) && dependency.getArtifactId().equals(project.getArtifactId())) { + throw new MojoExecutionException("Cannot add self to dependency management section"); + } + DependencyManagement dm = project.getDependencyManagement(); + if (dm != null) { + log.info(String.format("Adding dependency management entry %s:%s", key, dependency.getVersion())); + dm.addDependency(dependency); + } else { + throw new MojoExecutionException(String.format("Failed to add dependency management entry %s:%s because the project does not have a dependency management section", key, version)); + } + overrideAdditions.add(key); + } + } + unappliedOverrides.removeAll(overrideAdditions); + + // By now, we should have applied the entire override request. If not, fail. + if (!unappliedOverrides.isEmpty()) { + if (upperBounds) { + /* + * An upper bounds override that could not be found in the transitive tree is most likely a + * provided transitive dependency of a test-scoped dependency. We could ignore these, but + * we add them to the dependency management section just to be safe. + */ + for (String key : unappliedOverrides) { + String[] groupArt = key.split(":"); + Dependency dependency = new Dependency(); + dependency.setGroupId(groupArt[0]); + dependency.setArtifactId(groupArt[1]); + dependency.setVersion(overrides.get(key)); + if (dependency.getGroupId().equals(project.getGroupId()) && dependency.getArtifactId().equals(project.getArtifactId())) { + throw new MojoExecutionException("Cannot add self to dependency management section"); + } + DependencyManagement dm = project.getDependencyManagement(); + if (dm != null) { + log.info(String.format("Adding dependency management entry %s:%s", key, dependency.getVersion())); + dm.addDependency(dependency); + } else { + throw new MojoExecutionException(String.format("Failed to add dependency management entry %s:%s because the project does not have a dependency management section", key, overrides.get(key))); + } + overrideAdditions.add(key); + } + } else { + throw new MojoExecutionException("Failed to apply the following overrides: " + unappliedOverrides); + } + } + + /* + * If a bundled plugin was added that is neither in the model nor the transitive dependency + * chain, add a test-scoped direct dependency to the model. This is necessary in order for + * us to be able to correctly populate target/test-dependencies/ later on. + */ + Set unappliedBundledPlugins = new HashSet<>(bundledPlugins.keySet()); + unappliedBundledPlugins.removeAll(appliedBundledPlugins); + for (String key : unappliedBundledPlugins) { + String[] groupArt = key.split(":"); + String groupId = groupArt[0]; + String artifactId = groupArt[1]; + String version = bundledPlugins.get(key); + Dependency dependency = new Dependency(); + dependency.setGroupId(groupId); + dependency.setArtifactId(artifactId); + dependency.setVersion(version); + dependency.setScope(Artifact.SCOPE_TEST); + if (dependency.getGroupId().equals(project.getGroupId()) && dependency.getArtifactId().equals(project.getArtifactId())) { + throw new MojoExecutionException("Cannot add self as test-scoped dependency"); + } + log.info(String.format("Adding test-scoped direct dependency %s:%s", key, version)); + project.getDependencies().add(dependency); + } + + log.debug("adjusted dependencies: " + project.getDependencies()); + if (project.getDependencyManagement() != null) { + log.debug("adjusted dependency management: " + project.getDependencyManagement().getDependencies()); + } + + /* + * With our changes to the model, the existing resolution is now invalid, so clear it lest + * anything accidentally use the invalid values. We will perform resolution again after all + * passes are complete. + */ + project.setDependencyArtifacts(null); + project.setArtifacts(null); + } + + private static boolean updateDependency(Dependency dependency, Map overrides, String type, Log log) { + String key = toKey(dependency); + String overrideVersion = overrides.get(key); + if (overrideVersion != null) { + log.info(String.format("Updating %s %s from %s to %s", type, key, dependency.getVersion(), overrideVersion)); + dependency.setVersion(overrideVersion); + return true; + } + return false; + } + + /** + * Performs the equivalent of the "@requiresDependencyResolution" mojo attribute. + * + * @see LifecycleDependencyResolver#getDependencies(MavenProject, Collection, Collection, + * MavenSession, boolean, Set) + */ + private Set resolveDependencies(MavenProject project) throws MojoExecutionException { + try { + DependencyResolutionRequest request = new DefaultDependencyResolutionRequest(project, session.getRepositorySession()); + DependencyResolutionResult result = dependenciesResolver.resolve(request); + + Set artifacts = new LinkedHashSet<>(); + if (result.getDependencyGraph() != null && !result.getDependencyGraph().getChildren().isEmpty()) { + RepositoryUtils.toArtifacts( + artifacts, + result.getDependencyGraph().getChildren(), + Collections.singletonList(project.getArtifact().getId()), + request.getResolutionFilter()); + } + return artifacts; + } catch (DependencyResolutionException e) { + throw new MojoExecutionException("Unable to resolve dependencies", e); + } + } + + // Adapted from RequireUpperBoundDeps @ 731ea7a693a0986f2054b6a73a86a31373df59ec. + private class RequireUpperBoundDepsVisitor implements DependencyNodeVisitor { + + private Map> keyToPairsMap = new LinkedHashMap<>(); + + public boolean visit(DependencyNode node) { + DependencyNodeHopCountPair pair = new DependencyNodeHopCountPair(node); + String key = pair.constructKey(); + List pairs = keyToPairsMap.get(key); + if (pairs == null) { + pairs = new ArrayList<>(); + keyToPairsMap.put(key, pairs); + } + pairs.add(pair); + Collections.sort(pairs); + return true; + } + + public boolean endVisit(DependencyNode node) { + return true; + } + + // added for TestDependencyMojo in place of getConflicts/containsConflicts + public Map upperBounds(List upperBoundsExcludes) { + Map r = new HashMap<>(); + for (List pairs : keyToPairsMap.values()) { + DependencyNodeHopCountPair resolvedPair = pairs.get(0); + + // search for artifact with lowest hopCount + for (DependencyNodeHopCountPair hopPair : pairs.subList(1, pairs.size())) { + if (hopPair.getHopCount() < resolvedPair.getHopCount()) { + resolvedPair = hopPair; + } + } + + ArtifactVersion resolvedVersion = resolvedPair.extractArtifactVersion(false); + + for (DependencyNodeHopCountPair pair : pairs) { + ArtifactVersion version = pair.extractArtifactVersion(true); + if (resolvedVersion.compareTo(version) < 0) { + Artifact artifact = resolvedPair.node.getArtifact(); + String key = toKey(artifact); + if (!r.containsKey(key) || new ComparableVersion(version.toString()).compareTo(new ComparableVersion(r.get(key))) > 1) { + if (upperBoundsExcludes.contains(key)) { + getLog().info( "Ignoring requireUpperBoundDeps in " + key); + } else { + getLog().info(buildErrorMessage(pairs.stream().map(DependencyNodeHopCountPair::getNode).collect(Collectors.toList())).trim()); + getLog().info(String.format("for %s, upper bounds forces an upgrade from %s to %s", key, resolvedVersion, version)); + r.put(key, version.toString()); + } + } + } + } + } + return r; + } + } + + private static class DependencyNodeHopCountPair implements Comparable { + + private DependencyNode node; + + private int hopCount; + + private DependencyNodeHopCountPair(DependencyNode node) { + this.node = node; + countHops(); + } + + private void countHops() { + hopCount = 0; + DependencyNode parent = node.getParent(); + while (parent != null) { + hopCount++; + parent = parent.getParent(); + } + } + + private String constructKey() { + Artifact artifact = node.getArtifact(); + return toKey(artifact); + } + + public DependencyNode getNode() { + return node; + } + + private ArtifactVersion extractArtifactVersion(boolean usePremanagedVersion) { + if (usePremanagedVersion && node.getPremanagedVersion() != null) { + return new DefaultArtifactVersion(node.getPremanagedVersion()); + } + + Artifact artifact = node.getArtifact(); + String version = artifact.getBaseVersion(); + if (version != null) { + return new DefaultArtifactVersion(version); + } + try { + return artifact.getSelectedVersion(); + } catch (OverConstrainedVersionException e) { + throw new RuntimeException("Version ranges problem with " + node.getArtifact(), e); + } + } + + public int getHopCount() { + return hopCount; + } + + @SuppressFBWarnings( + value = "EQ_COMPARETO_USE_OBJECT_EQUALS", + justification = "Silly check; it is perfectly reasonable to implement Comparable by writing a compareTo without an equals.") + public int compareTo(DependencyNodeHopCountPair other) { + return Integer.compare(hopCount, other.getHopCount()); + } + } + + private static String buildErrorMessage(List conflict) { + StringBuilder errorMessage = new StringBuilder(); + errorMessage.append( + "Require upper bound dependencies error for " + + getFullArtifactName(conflict.get(0), false) + + " paths to dependency are:" + + System.lineSeparator()); + if (conflict.size() > 0) { + errorMessage.append(buildTreeString(conflict.get(0))); + } + for (DependencyNode node : conflict.subList(1, conflict.size())) { + errorMessage.append("and" + System.lineSeparator()); + errorMessage.append(buildTreeString(node)); + } + return errorMessage.toString(); + } + + private static StringBuilder buildTreeString(DependencyNode node) { + List loc = new ArrayList<>(); + DependencyNode currentNode = node; + while (currentNode != null) { + StringBuilder line = new StringBuilder(getFullArtifactName(currentNode, false)); + + if (currentNode.getPremanagedVersion() != null) { + line.append(" (managed) <-- "); + line.append(getFullArtifactName(currentNode, true)); + } + + loc.add(line.toString()); + currentNode = currentNode.getParent(); + } + Collections.reverse(loc); + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < loc.size(); i++) { + for (int j = 0; j < i; j++) { + builder.append(" "); + } + builder.append("+-").append(loc.get(i)); + builder.append(System.lineSeparator()); + } + return builder; + } + + private static String getFullArtifactName(DependencyNode node, boolean usePremanaged) { + Artifact artifact = node.getArtifact(); + + String version = node.getPremanagedVersion(); + if (!usePremanaged || version == null) { + version = artifact.getBaseVersion(); + } + String result = artifact.getGroupId() + ":" + artifact.getArtifactId() + ":" + version; + + String classifier = artifact.getClassifier(); + if (classifier != null && !classifier.isEmpty()) { + result += ":" + classifier; + } + + String scope = artifact.getScope(); + if (scope != null) { + result += " [" + scope + ']'; + } + + return result; + } + + private static String toKey(Artifact artifact) { + return artifact.getGroupId() + ":" + artifact.getArtifactId(); + } + + private static String toKey(Dependency dependency) { + return dependency.getGroupId() + ":" + dependency.getArtifactId(); } }