Skip to content
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.jenkins.tools.pluginmodernizer.cli;

import io.jenkins.tools.pluginmodernizer.cli.command.BuildMetadataCommand;
import io.jenkins.tools.pluginmodernizer.cli.command.CampaignCommand;
import io.jenkins.tools.pluginmodernizer.cli.command.CleanupCommand;
import io.jenkins.tools.pluginmodernizer.cli.command.DryRunCommand;
import io.jenkins.tools.pluginmodernizer.cli.command.ListRecipesCommand;
Expand All @@ -22,6 +23,7 @@
ValidateCommand.class,
ListRecipesCommand.class,
BuildMetadataCommand.class,
CampaignCommand.class,
DryRunCommand.class,
RunCommand.class,
CleanupCommand.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package io.jenkins.tools.pluginmodernizer.cli.command;

import com.google.inject.Guice;
import io.jenkins.tools.pluginmodernizer.cli.options.EnvOptions;
import io.jenkins.tools.pluginmodernizer.cli.options.GitHubOptions;
import io.jenkins.tools.pluginmodernizer.cli.options.GlobalOptions;
import io.jenkins.tools.pluginmodernizer.core.GuiceModule;
import io.jenkins.tools.pluginmodernizer.core.campaign.CampaignReport;
import io.jenkins.tools.pluginmodernizer.core.campaign.CampaignService;
import io.jenkins.tools.pluginmodernizer.core.config.Config;
import io.jenkins.tools.pluginmodernizer.core.model.ModernizerException;
import java.nio.file.Path;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import picocli.CommandLine;

/**
* Campaign command.
*/
@CommandLine.Command(
name = "campaign",
description = "Run a multi-stage modernization campaign in dry-run mode and emit a structured JSON report")
public class CampaignCommand implements ICommand {

private static final Logger LOG = LoggerFactory.getLogger(CampaignCommand.class);

@CommandLine.Option(
names = {"--file"},
required = true,
description = "Path to the campaign YAML file.")
private Path file;

@CommandLine.Mixin
private EnvOptions envOptions;

@CommandLine.Mixin
private GlobalOptions options = GlobalOptions.getInstance();

@CommandLine.Mixin
private GitHubOptions githubOptions;

@Override
public Config setup(Config.Builder builder) {
options.config(builder);
envOptions.config(builder);
githubOptions.config(builder);
return builder.withDryRun(true).build();
}

@Override
public Integer call() {
try {
CampaignService campaignService = Guice.createInjector(new GuiceModule(setup(Config.builder())))
.getInstance(CampaignService.class);
CampaignReport report = campaignService.run(file);
LOG.info(
"Campaign finished. Plugins: {} success / {} failed. Report: {}",
report.getSuccessfulPlugins(),
report.getFailedPlugins(),
report.getReportJson());
return report.getFailedStages() > 0 ? 1 : 0;
} catch (ModernizerException e) {
LOG.error("Campaign validation error");
LOG.error(e.getMessage());
return 1;
} catch (RuntimeException e) {
LOG.error("Campaign execution failed");
LOG.error(e.getMessage() != null ? e.getMessage() : e.getClass().getName());
return 1;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,97 +1,17 @@
package io.jenkins.tools.pluginmodernizer.cli.converter;

import io.jenkins.tools.pluginmodernizer.core.model.Plugin;
import io.jenkins.tools.pluginmodernizer.core.utils.StaticPomParser;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import io.jenkins.tools.pluginmodernizer.core.utils.PluginPathResolver;
import picocli.CommandLine;

/**
* Custom converter to get a list of plugin from a local folder
*/
public class PluginPathConverter implements CommandLine.ITypeConverter<Plugin> {

private static final Logger LOG = LoggerFactory.getLogger(PluginPathConverter.class);
private final PluginPathResolver pluginPathResolver = new PluginPathResolver();

@Override
public Plugin convert(String value) throws Exception {
Path path = Path.of(value);
if (!Files.isDirectory(path)) {
throw new IllegalArgumentException("Path is not a directory: " + path);
}

Path pom = path.resolve("pom.xml");
if (!Files.exists(pom)) {
throw new IllegalArgumentException("Path does not contain a pom.xml: " + path);
}

StaticPomParser rootPomParser = new StaticPomParser(pom.toString());
String packaging = rootPomParser.getPackaging();

// Check if this is a single-module Jenkins plugin
if ("hpi".equals(packaging)) {
String artifactId = rootPomParser.getArtifactId();
if (artifactId == null) {
throw new IllegalArgumentException("Path does not contain a valid Jenkins plugin: " + path);
}
LOG.info("Found single-module plugin '{}' at root level", artifactId);
return Plugin.build(artifactId, path);
}

// Check if this is a multi-module project (packaging = pom)
if ("pom".equals(packaging) || packaging == null || packaging.isEmpty()) {
LOG.info("Detected multi-module project, searching for Jenkins plugin module...");
Path pluginPath = findJenkinsPluginModule(path);
if (pluginPath != null) {
StaticPomParser pluginPomParser =
new StaticPomParser(pluginPath.resolve("pom.xml").toString());
String artifactId = pluginPomParser.getArtifactId();
if (artifactId == null) {
throw new IllegalArgumentException(
"Plugin module does not contain valid artifactId: " + pluginPath);
}
LOG.info("Found Jenkins plugin module '{}' at: {}", artifactId, pluginPath);
return Plugin.build(artifactId, pluginPath);
}
throw new IllegalArgumentException(
"Multi-module project detected but no module with packaging 'hpi' found" + path);
}

throw new IllegalArgumentException(
"Path does not contain a Jenkins plugin (packaging must be 'hpi' or a multi-module project with an hpi module): "
+ path);
}

/**
* Find the Jenkins plugin module in a multi-module project.
* Searches all subdirectories for a pom.xml with packaging 'hpi'.
*
* @param rootPath The root path of the multi-module project
* @return The path to the plugin module, or null if not found
* @throws IOException if an I/O error occurs
*/
private Path findJenkinsPluginModule(Path rootPath) throws IOException {
try (Stream<Path> paths = Files.walk(rootPath, 2)) { // Search up to 2 levels deep
return paths.filter(Files::isDirectory)
.filter(dir -> !dir.equals(rootPath)) // Skip root directory
.filter(dir -> Files.exists(dir.resolve("pom.xml")))
.filter(dir -> {
try {
StaticPomParser parser =
new StaticPomParser(dir.resolve("pom.xml").toString());
String packaging = parser.getPackaging();
return "hpi".equals(packaging);
} catch (Exception e) {
LOG.debug("Failed to parse pom.xml in {}: {}", dir, e.getMessage());
return false;
}
})
.findFirst()
.orElse(null);
}
return pluginPathResolver.resolve(value);
}
}
Original file line number Diff line number Diff line change
@@ -1,30 +1,23 @@
package io.jenkins.tools.pluginmodernizer.cli.converter;

import io.jenkins.tools.pluginmodernizer.core.config.Settings;
import io.jenkins.tools.pluginmodernizer.core.model.Recipe;
import io.jenkins.tools.pluginmodernizer.core.utils.RecipeResolver;
import java.util.Iterator;
import picocli.CommandLine;

/**
* Custom converter for Recipe interface.
*/
public final class RecipeConverter implements CommandLine.ITypeConverter<Recipe>, Iterable<String> {
private final RecipeResolver recipeResolver = new RecipeResolver();

@Override
public Recipe convert(String value) {
return Settings.AVAILABLE_RECIPES.stream()
// Compare without and without the FQDN prefix
.filter(recipe -> recipe.getName().equals(value)
|| recipe.getName()
.replace(Settings.RECIPE_FQDN_PREFIX + ".", "")
.equals(value))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Invalid recipe name: " + value));
return recipeResolver.resolve(value);
}

@Override
public Iterator<String> iterator() {
return Settings.AVAILABLE_RECIPES.stream()
.map(r -> r.getName().replace(Settings.RECIPE_FQDN_PREFIX + ".", ""))
.iterator();
return recipeResolver.candidates().iterator();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import com.github.tomakehurst.wiremock.junit5.WireMockTest;
import io.jenkins.tools.pluginmodernizer.cli.utils.GitHubServerContainer;
import io.jenkins.tools.pluginmodernizer.cli.utils.ModernizerTestWatcher;
import io.jenkins.tools.pluginmodernizer.core.campaign.CampaignReport;
import io.jenkins.tools.pluginmodernizer.core.config.Settings;
import io.jenkins.tools.pluginmodernizer.core.extractor.ArchetypeCommonFile;
import io.jenkins.tools.pluginmodernizer.core.extractor.PluginMetadata;
Expand Down Expand Up @@ -556,6 +557,90 @@ public void testRecipeOnLocalPlugin(WireMockRuntimeInfo wmRuntimeInfo) throws Ex
}
}

@Test
@Tag("Slow")
public void testCampaignOnLocalPlugin(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {

Path logFile = setupLogs("testCampaignOnLocalPlugin");

final String plugin = "empty";
final Path pluginPath = Path.of("src/test/resources").resolve(plugin);
Path targetPath = cachePath
.resolve("jenkins-plugin-modernizer-cli")
.resolve(plugin)
.resolve("sources");
FileUtils.copyDirectory(pluginPath.toFile(), targetPath.toFile());
Git.init().setDirectory(targetPath.toFile()).call().close();

Path campaignFile = cachePath.resolve("campaign.yaml");
Files.writeString(campaignFile, """
plugins:
localPaths:
- %s
stages:
- recipe: SetupDependabot
- recipe: SetupSecurityScan
execution:
concurrency: 1
continueOnFailure: false
skipMetadata: true
output:
reportJson: reports/campaign.json
""".formatted(targetPath.toAbsolutePath()));

try (GitHubServerContainer gitRemote = new GitHubServerContainer(wmRuntimeInfo, keysPath, plugin, "main")) {

gitRemote.start();

System.out.printf("[[ATTACHMENT|%s]]%n", getMavenInvokerLog(plugin));
System.out.printf("[[ATTACHMENT|%s]]%n", logFile.toAbsolutePath());

Invoker invoker = buildInvoker();
InvocationRequest request = buildRequest(
"""
campaign --file %s
--debug
--maven-home %s
--ssh-private-key %s
--cache-path %s
--github-api-url %s
--jenkins-update-center %s
--jenkins-plugin-info %s
--plugin-health-score %s
--jenkins-plugins-stats-installations-url %s
""".formatted(
campaignFile.toAbsolutePath(),
getModernizerMavenHome(),
keysPath.resolve(plugin),
cachePath,
wmRuntimeInfo.getHttpBaseUrl() + "/api",
wmRuntimeInfo.getHttpBaseUrl() + "/update-center.json",
wmRuntimeInfo.getHttpBaseUrl() + "/plugin-versions.json",
wmRuntimeInfo.getHttpBaseUrl() + "/scores",
wmRuntimeInfo.getHttpBaseUrl() + "/jenkins-stats/svg/202406-plugins.csv")
.replaceAll("\\s+", " "),
logFile);
InvocationResult result = invoker.execute(request);

Path reportPath = cachePath.resolve("reports").resolve("campaign.json");
CampaignReport report = JsonUtils.fromJson(reportPath, CampaignReport.class);

assertAll(
() -> assertEquals(0, result.getExitCode()),
() -> assertTrue(Files.readAllLines(logFile).stream()
.anyMatch(
line -> line.matches("(.*)Campaign finished. Plugins: 1 success / 0 failed.(.*)"))),
() -> assertTrue(Files.exists(targetPath.resolve(".github").resolve("dependabot.yml"))),
() -> assertTrue(Files.exists(targetPath.resolve(ArchetypeCommonFile.WORKFLOW_SECURITY.getPath()))),
() -> assertTrue(Files.exists(reportPath)),
() -> assertEquals(1, report.getSuccessfulPlugins()),
() -> assertEquals(2, report.getSuccessfulStages()),
() -> assertEquals(
targetPath.toAbsolutePath().toString(),
report.getPlugins().get(0).getFinalLocalRepository()));
}
}

@Test
@Tag("CI")
public void testComplexRecipeOnLocalPlugin(WireMockRuntimeInfo wmRuntimeInfo) throws Exception {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package io.jenkins.tools.pluginmodernizer.core;

import com.google.inject.AbstractModule;
import io.jenkins.tools.pluginmodernizer.core.campaign.CampaignModernizerRunner;
import io.jenkins.tools.pluginmodernizer.core.campaign.DefaultCampaignModernizerRunner;
import io.jenkins.tools.pluginmodernizer.core.config.Config;
import io.jenkins.tools.pluginmodernizer.core.github.GHService;
import io.jenkins.tools.pluginmodernizer.core.impl.CacheManager;
Expand All @@ -21,6 +23,7 @@ public GuiceModule(Config config) {
@Override
protected void configure() {
bind(Invoker.class).to(DefaultInvoker.class);
bind(CampaignModernizerRunner.class).to(DefaultCampaignModernizerRunner.class);
bind(Config.class).toInstance(config);
bind(CacheManager.class).toInstance(new CacheManager(config.getCachePath()));
bind(PluginService.class).toInstance(new PluginService());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package io.jenkins.tools.pluginmodernizer.core.campaign;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import java.util.List;

/**
* Top-level campaign definition loaded from YAML.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public class CampaignDefinition {

private CampaignPluginSource plugins = new CampaignPluginSource();
private List<CampaignStage> stages;
private CampaignExecution execution = new CampaignExecution();
private CampaignOutput output = new CampaignOutput();

public CampaignPluginSource getPlugins() {
return plugins;
}

public void setPlugins(CampaignPluginSource plugins) {
this.plugins = plugins;
}

public List<CampaignStage> getStages() {
return stages;
}

public void setStages(List<CampaignStage> stages) {
this.stages = stages;
}

public CampaignExecution getExecution() {
return execution;
}

public void setExecution(CampaignExecution execution) {
this.execution = execution;
}

public CampaignOutput getOutput() {
return output;
}

public void setOutput(CampaignOutput output) {
this.output = output;
}
}
Loading
Loading