diff --git a/.gitignore b/.gitignore index 7e8f5e6..74b37b2 100644 --- a/.gitignore +++ b/.gitignore @@ -142,3 +142,6 @@ hs_err_pid* /.idea/misc.xml /.idea/uiDesigner.xml /.idea/vcs.xml + +# local gradle stuff +.gradle/ \ No newline at end of file diff --git a/build.gradle b/build.gradle index 8ff0474..9cf31ad 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,7 @@ dependencies { implementation group: 'pw.chew', name: 'jda-chewtils-commons', version: '2.0-SNAPSHOT' implementation group: 'pw.chew', name: 'jda-chewtils-command', version: '2.0-SNAPSHOT' implementation group: 'org.spongepowered', name: 'configurate-gson', version: '4.1.2' + implementation group: 'io.github.cdancy', name: 'jenkins-rest', version: '1.0.2' } artifacts { diff --git a/src/main/java/io/codemc/bot/CodeMCBot.java b/src/main/java/io/codemc/bot/CodeMCBot.java index 0e70bed..10cb801 100644 --- a/src/main/java/io/codemc/bot/CodeMCBot.java +++ b/src/main/java/io/codemc/bot/CodeMCBot.java @@ -21,6 +21,7 @@ import com.jagrosh.jdautilities.command.CommandClientBuilder; import io.codemc.bot.commands.*; import io.codemc.bot.config.ConfigHandler; +import io.codemc.bot.jenkins.JenkinsAPI; import io.codemc.bot.listeners.ModalListener; import io.codemc.bot.menu.ApplicationMenu; import net.dv8tion.jda.api.JDABuilder; @@ -37,6 +38,7 @@ public class CodeMCBot{ private final Logger logger = LoggerFactory.getLogger(CodeMCBot.class); private final ConfigHandler configHandler = new ConfigHandler(); + private final JenkinsAPI jenkins = new JenkinsAPI(this); public static void main(String[] args){ try{ @@ -90,6 +92,13 @@ private void start() throws LoginException{ clientBuilder.setCoOwnerIds(coOwnerIds); } + + logger.info("Pinging Jenkins..."); + if(!jenkins.ping()){ + logger.warn("Unable to ping Jenkins! Please check your configuration."); + System.exit(1); + return; + } logger.info("Adding commands..."); clientBuilder.addSlashCommands( @@ -105,7 +114,7 @@ private void start() throws LoginException{ new ApplicationMenu.Accept(this), new ApplicationMenu.Deny(this) ); - + logger.info("Starting bot..."); JDABuilder.createDefault(token) .enableIntents( @@ -124,8 +133,16 @@ private void start() throws LoginException{ ) .build(); } + + public Logger getLogger(){ + return logger; + } public ConfigHandler getConfigHandler(){ return configHandler; } + + public JenkinsAPI getJenkins(){ + return jenkins; + } } diff --git a/src/main/java/io/codemc/bot/commands/CmdApplication.java b/src/main/java/io/codemc/bot/commands/CmdApplication.java index f459e78..828f330 100644 --- a/src/main/java/io/codemc/bot/commands/CmdApplication.java +++ b/src/main/java/io/codemc/bot/commands/CmdApplication.java @@ -40,10 +40,13 @@ import java.util.Arrays; import java.util.List; +import java.util.regex.Matcher; import java.util.regex.Pattern; public class CmdApplication extends BotCommand{ - + + public static final Pattern PROJECT_URL_PATTERN = Pattern.compile("^https://ci\\.codemc\\.io/job/([a-zA-Z0-9-]+)/job/([a-zA-Z0-9-_.]+)/?$"); + public CmdApplication(CodeMCBot bot){ super(bot); @@ -64,7 +67,7 @@ public void withModalReply(SlashCommandEvent event){} @Override public void withHookReply(InteractionHook hook, SlashCommandEvent event, Guild guild, Member member){} - public static void handle(CodeMCBot bot, InteractionHook hook, Guild guild, long messageId, String str, boolean accepted){ + public static void handle(CodeMCBot bot, InteractionHook hook, Guild guild, long messageId, String str, boolean accepted, boolean freestyle){ TextChannel requestChannel = guild.getTextChannelById(bot.getConfigHandler().getLong("channel", "request_access")); if(requestChannel == null){ CommandUtil.EmbedReply.fromHook(hook).withError("Unable to retrieve `request-access` channel.").send(); @@ -121,7 +124,8 @@ public static void handle(CodeMCBot bot, InteractionHook hook, Guild guild, long .send(); return; } - + + String finalRepoLink = repoLink; channel.sendMessage(getMessage(bot, userId, userLink, repoLink, str, accepted)).queue(m -> { ThreadChannel thread = message.getStartedThread(); if(thread != null && !thread.isArchived()){ @@ -141,6 +145,33 @@ public static void handle(CodeMCBot bot, InteractionHook hook, Guild guild, long .send(); return; } + + Matcher matcher = PROJECT_URL_PATTERN.matcher(str); + if (!matcher.matches()) { + CommandUtil.EmbedReply.fromHook(hook) + .withError("The provided Project URL did not match the pattern `https://ci.codemc.io/job//job/`!") + .send(); + return; + } + + String username = matcher.group(1); + String project = matcher.group(2); + + boolean userSuccess = bot.getJenkins().createJenkinsUser(username); + if (!userSuccess) { + CommandUtil.EmbedReply.fromHook(hook) + .withError("Failed to create Jenkins user for " + username + "! Manual creation required.") + .send(); + return; + } + + boolean jobSuccess = bot.getJenkins().createJenkinsJob(username, project, finalRepoLink, freestyle); + if (!jobSuccess) { + CommandUtil.EmbedReply.fromHook(hook) + .withError("Failed to create Jenkins job for " + username + "! Manual creation required.") + .send(); + return; + } Role authorRole = guild.getRoleById(bot.getConfigHandler().getLong("author_role")); if(authorRole == null){ @@ -195,9 +226,7 @@ private static MessageCreateData getMessage(CodeMCBot bot, String userId, String } private static class Accept extends BotCommand{ - - private final Pattern projectUrlPattern = Pattern.compile("^https://ci\\.codemc\\.io/job/[a-zA-Z0-9-]+/job/[a-zA-Z0-9-_.]+/?$"); - + public Accept(CodeMCBot bot){ super(bot); @@ -208,7 +237,8 @@ public Accept(CodeMCBot bot){ this.options = Arrays.asList( new OptionData(OptionType.STRING, "id", "The message id of the application.").setRequired(true), - new OptionData(OptionType.STRING, "project-url", "The URL of the newly made Project.").setRequired(true) + new OptionData(OptionType.STRING, "project-url", "The URL of the newly made Project. Their username should reflect their GitHub username, not their Discord username.").setRequired(true), + new OptionData(OptionType.BOOLEAN, "freestyle", "False if built with Maven. If built with something other than Maven (such as Gradle), set to true.").setRequired(true) ); } @@ -225,6 +255,7 @@ public void withHookReply(InteractionHook hook, SlashCommandEvent event, Guild g } }); String projectUrl = event.getOption("project-url", null, OptionMapping::getAsString); + boolean freestyle = event.getOption("freestyle", false, OptionMapping::getAsBoolean); if(messageId == -1L || projectUrl == null){ CommandUtil.EmbedReply.fromHook(hook).withError( @@ -232,15 +263,15 @@ public void withHookReply(InteractionHook hook, SlashCommandEvent event, Guild g ).send(); return; } - - if(!projectUrlPattern.matcher(projectUrl).matches()){ + + if(!PROJECT_URL_PATTERN.matcher(projectUrl).matches()){ CommandUtil.EmbedReply.fromHook(hook).withError( "The provided Project URL did not match the pattern `https://ci.codemc.io/job//job/`!" ).send(); return; } - handle(bot, hook, guild, messageId, projectUrl, true); + handle(bot, hook, guild, messageId, projectUrl, true, freestyle); } } @@ -281,7 +312,7 @@ public void withHookReply(InteractionHook hook, SlashCommandEvent event, Guild g return; } - handle(bot, hook, guild, messageId, reason, false); + handle(bot, hook, guild, messageId, reason, false, false); } } } diff --git a/src/main/java/io/codemc/bot/jenkins/JenkinsAPI.java b/src/main/java/io/codemc/bot/jenkins/JenkinsAPI.java new file mode 100644 index 0000000..feca7e4 --- /dev/null +++ b/src/main/java/io/codemc/bot/jenkins/JenkinsAPI.java @@ -0,0 +1,115 @@ +/* + * Copyright 2024 CodeMC.io + * + * 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 io.codemc.bot.jenkins; + +import com.cdancy.jenkins.rest.JenkinsClient; +import com.cdancy.jenkins.rest.domain.common.RequestStatus; +import com.cdancy.jenkins.rest.domain.system.SystemInfo; +import io.codemc.bot.CodeMCBot; +import io.codemc.bot.config.ConfigHandler; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Files; +import java.util.stream.Collectors; + +public class JenkinsAPI { + + private final CodeMCBot bot; + private final JenkinsClient client; + + public JenkinsAPI(CodeMCBot bot){ + this.bot = bot; + + ConfigHandler config = bot.getConfigHandler(); + String url = config.getString("jenkins", "url"); + String username = config.getString("jenkins", "username"); + String token = config.getString("jenkins", "token"); + this.client = JenkinsClient.builder() + .endPoint(url) + .credentials(username + ":" + token) + .build(); + } + + public boolean ping(){ + SystemInfo info = client.api().systemApi().systemInfo(); + return info != null; + } + + // Templates + + private String template(String path){ + try(InputStream stream = CodeMCBot.class.getResourceAsStream(path)) { + if (stream == null) return null; + try (InputStreamReader isr = new InputStreamReader(stream)) { + BufferedReader reader = new BufferedReader(isr); + return reader.lines().collect(Collectors.joining(System.lineSeparator())); + } catch (IOException e) { + bot.getLogger().error("Error reading {}", path, e); + return null; + } + } catch (IOException e) { + bot.getLogger().error("Error finding {}", path, e); + return null; + } + } + + private String jenkinsUserTemplate(){ + return template("/template-user-config.xml"); + } + + private String jenkinsMavenJob(){ + return template("/template-job-maven.xml"); + } + + private String jenkinsFreestyleJob(){ + return template("/template-job-freestyle.xml"); + } + + // Functions + + private String createJenkinsConfig(String username){ + String template = jenkinsUserTemplate(); + if (template == null) return null; + + return template.replace("{USERNAME}", username); + } + + public boolean createJenkinsUser(String username){ + String config = createJenkinsConfig(username); + if (config == null) return false; + + RequestStatus status = client.api().jobsApi().create("/", username, config); + return status.value(); + } + + public boolean createJenkinsJob(String username, String jobName, String repoLink, boolean isFreestyle){ + String template = isFreestyle ? jenkinsFreestyleJob() : jenkinsMavenJob(); + if (template == null) return false; + + template = template.replace("{PROJECT_URL}", repoLink); + + // Jenkins will automatically add job to the URL + RequestStatus status = client.api().jobsApi().create(username, jobName, template); + return status.value(); + } + +} diff --git a/src/main/resources/config.json b/src/main/resources/config.json index f14ee16..a7c7899 100644 --- a/src/main/resources/config.json +++ b/src/main/resources/config.json @@ -20,5 +20,10 @@ "messages": { "accepted": [], "denied": [] + }, + "jenkins": { + "url": "URL", + "username": "USERNAME", + "token": "API TOKEN" } } \ No newline at end of file diff --git a/src/main/resources/config.json.sample b/src/main/resources/config.json.sample index b834ba3..6363632 100644 --- a/src/main/resources/config.json.sample +++ b/src/main/resources/config.json.sample @@ -43,5 +43,10 @@ "", "You may re-apply unless mentioned otherwise in the Reason." ] + }, + "jenkins": { + "url": "https://ci.codemc.io", + "username": "admin", + "token": "admin_api_token" } } \ No newline at end of file diff --git a/src/main/resources/template-job-freestyle.xml b/src/main/resources/template-job-freestyle.xml new file mode 100644 index 0000000..20460ea --- /dev/null +++ b/src/main/resources/template-job-freestyle.xml @@ -0,0 +1,58 @@ + + + + + false + + + {PROJECT_URL} + + + + + + false + + + + 2 + + + {PROJECT_URL} + + + + + */master + + + false + + + + true + false + false + false + (System) + + + H/15 * * * * + false + + + false + + + + **/build/libs/*.jar + false + false + false + true + true + false + + + + \ No newline at end of file diff --git a/src/main/resources/template-job-maven.xml b/src/main/resources/template-job-maven.xml new file mode 100644 index 0000000..7e999c0 --- /dev/null +++ b/src/main/resources/template-job-maven.xml @@ -0,0 +1,92 @@ + + + false + + + + + false + + false + + + + {PROJECT_URL} + + + + + + false + + + + 2 + + + {PROJECT_URL} + + + + + */master + + + false + + + + true + false + false + false + (System) + + + H/15 * * * * + false + + + false + clean package -B + Latest + true + false + false + false + false + false + false + false + false + -1 + false + false + true + + + e5b005b5-be4d-4709-8657-1981662bcbe3 + + + + + **/target/*.jar + **/original-*.jar + false + false + false + true + true + false + + + + + + + FAILURE + 2 + RED + true + + \ No newline at end of file diff --git a/src/main/resources/template-user-config.xml b/src/main/resources/template-user-config.xml new file mode 100644 index 0000000..7ac15d0 --- /dev/null +++ b/src/main/resources/template-user-config.xml @@ -0,0 +1,76 @@ + + + + + + + + + + + + + USER:com.cloudbees.plugins.credentials.CredentialsProvider.Create:{USERNAME} + USER:com.cloudbees.plugins.credentials.CredentialsProvider.Delete:{USERNAME} + USER:com.cloudbees.plugins.credentials.CredentialsProvider.ManageDomains:{USERNAME} + USER:com.cloudbees.plugins.credentials.CredentialsProvider.Update:{USERNAME} + USER:com.cloudbees.plugins.credentials.CredentialsProvider.View:{USERNAME} + USER:hudson.model.Item.Build:{USERNAME} + USER:hudson.model.Item.Cancel:{USERNAME} + USER:hudson.model.Item.Configure:{USERNAME} + USER:hudson.model.Item.Create:{USERNAME} + USER:hudson.model.Item.Delete:{USERNAME} + USER:hudson.model.Item.Discover:{USERNAME} + USER:hudson.model.Item.Move:{USERNAME} + USER:hudson.model.Item.Read:{USERNAME} + USER:hudson.model.Item.ViewStatus:{USERNAME} + USER:hudson.model.Item.Workspace:{USERNAME} + USER:hudson.model.Run.Delete:{USERNAME} + USER:hudson.model.Run.Replay:{USERNAME} + USER:hudson.model.Run.Update:{USERNAME} + USER:hudson.model.View.Configure:{USERNAME} + USER:hudson.model.View.Create:{USERNAME} + USER:hudson.model.View.Delete:{USERNAME} + USER:hudson.model.View.Read:{USERNAME} + USER:hudson.plugins.promoted_builds.Promotion.Promote:{USERNAME} + USER:hudson.scm.SCM.Tag:{USERNAME} + + + + + + + + + + + + + + + + + + + false + + + + + + + All + false + false + + + + + + + + false + + + + \ No newline at end of file