diff --git a/pom.xml b/pom.xml index 8c7293e..0eb5f0f 100644 --- a/pom.xml +++ b/pom.xml @@ -1,100 +1,142 @@ - - 4.0.0 - - - org.jenkins-ci.plugins - plugin - 4.87 - - - - Discord Notifier - https://github.com/jenkinsci/discord-notifier-plugin - - nz.co.jammehcow - discord-notifier - ${changelist} - hpi - - - 999999-SNAPSHOT - jenkinsci/discord-notifier-plugin - 2.401.3 - Max - Medium - - - - scm:git:https://github.com/${gitHubRepo}.git - scm:git:git@github.com:${gitHubRepo}.git - https://github.com/${gitHubRepo} - ${scmTag} - - - - - MIT License - https://opensource.org/licenses/MIT - - - - - - jammehcow - James Upjohn - jspartan250@gmail.com - - - KocproZ - Kacper Stasiuk - kocproz@protonmail.com - - - - - - repo.jenkins-ci.org - https://repo.jenkins-ci.org/public/ - - - - - - repo.jenkins-ci.org - https://repo.jenkins-ci.org/public/ - - - - + + 4.0.0 + + + org.jenkins-ci.plugins + plugin + 4.87 + + + + Discord Notifier + https://github.com/jenkinsci/discord-notifier-plugin + + nz.co.jammehcow + discord-notifier + ${changelist} + hpi + + + 999999-SNAPSHOT + jenkinsci/discord-notifier-plugin + 2.401.3 + Max + Medium + + + + scm:git:https://github.com/${gitHubRepo}.git + scm:git:git@github.com:${gitHubRepo}.git + https://github.com/${gitHubRepo} + ${scmTag} + + + + + MIT License + https://opensource.org/licenses/MIT + + + + + + jammehcow + James Upjohn + jspartan250@gmail.com + + + KocproZ + Kacper Stasiuk + kocproz@protonmail.com + + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + + repo.jenkins-ci.org + https://repo.jenkins-ci.org/public/ + + + + + + + io.jenkins.tools.bom + bom-2.401.x + 2745.vc7b_fe4c876fa_ + import + pom + + + + - - io.jenkins.tools.bom - bom-2.401.x - 2745.vc7b_fe4c876fa_ - import - pom - + + + org.jenkins-ci.plugins.workflow + workflow-step-api + + + + + org.jenkins-ci.plugins + matrix-project + + + + club.minnced + discord-webhooks + 0.8.4 + + + + + org.json + json + 20231013 + + + + org.jetbrains.kotlin + kotlin-stdlib + 1.6.20 + + + + + org.jenkins-ci.plugins.workflow + workflow-basic-steps + test + + + org.jenkins-ci.plugins.workflow + workflow-cps + test + + + org.jenkins-ci.plugins.workflow + workflow-job + test + + + org.jenkins-ci.plugins + junit + test + + + org.jenkins-ci.plugins.workflow + workflow-step-api + tests + test + - - - - - com.konghq - unirest-java - 3.14.5 - - - - - org.jenkins-ci.plugins.workflow - workflow-step-api - - - - - org.jenkins-ci.plugins - matrix-project - - diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/DiscordPipelineStep.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/DiscordPipelineStep.java index b51f96c..525a466 100644 --- a/src/main/java/nz/co/jammehcow/jenkinsdiscord/DiscordPipelineStep.java +++ b/src/main/java/nz/co/jammehcow/jenkinsdiscord/DiscordPipelineStep.java @@ -1,52 +1,44 @@ package nz.co.jammehcow.jenkinsdiscord; -import static nz.co.jammehcow.jenkinsdiscord.DiscordWebhook.DESCRIPTION_LIMIT; -import static nz.co.jammehcow.jenkinsdiscord.DiscordWebhook.FOOTER_LIMIT; -import static nz.co.jammehcow.jenkinsdiscord.DiscordWebhook.StatusColor; -import static nz.co.jammehcow.jenkinsdiscord.DiscordWebhook.TITLE_LIMIT; - +import club.minnced.discord.webhook.WebhookClient; +import club.minnced.discord.webhook.send.WebhookMessage; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Extension; -import hudson.FilePath; -import hudson.model.Result; import hudson.model.Run; import hudson.model.TaskListener; -import javax.inject.Inject; import jenkins.model.JenkinsLocationConfiguration; -import nz.co.jammehcow.jenkinsdiscord.exception.WebhookException; -import nz.co.jammehcow.jenkinsdiscord.util.EmbedDescription; +import nz.co.jammehcow.jenkinsdiscord.util.EmbedUtil; +import org.apache.commons.lang.StringUtils; import org.jenkinsci.plugins.workflow.steps.AbstractStepDescriptorImpl; import org.jenkinsci.plugins.workflow.steps.AbstractStepImpl; -import org.jenkinsci.plugins.workflow.steps.AbstractSynchronousNonBlockingStepExecution; -import org.jenkinsci.plugins.workflow.steps.StepContextParameter; +import org.jenkinsci.plugins.workflow.steps.StepContext; +import org.jenkinsci.plugins.workflow.steps.StepExecution; +import org.jenkinsci.plugins.workflow.steps.SynchronousNonBlockingStepExecution; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.io.InputStream; -import java.nio.charset.Charset; -import java.nio.file.InvalidPathException; + public class DiscordPipelineStep extends AbstractStepImpl { private final String webhookURL; - private String title; - private String link; - private String description; - private String footer; - private String image; - private String thumbnail; - private String result; - private String notes; - private String customAvatarUrl; - private String customUsername; - private String customFile; - private DynamicFieldContainer dynamicFieldContainer; - private boolean successful; - private boolean unstable; - private boolean enableArtifactsList; - private boolean showChangeset; - private String scmWebUrl; + private String title = null; + private String link = null; + private String description = null; + private String footer = null; + private String image = null; + private String thumbnail = null; + private String result = null; + private String notes = null; + private String customAvatarUrl = null; + private String customUsername = null; + private String customFile = null; + private DynamicFieldContainer dynamicFieldContainer = null; + private boolean successful = false; + private boolean unstable = false; + private boolean enableArtifactsList = false; + private boolean prettyArtifactsList = false; + private boolean showChangeset = false; + private String scmWebUrl = null; @DataBoundConstructor public DiscordPipelineStep(String webhookURL) { @@ -54,47 +46,47 @@ public DiscordPipelineStep(String webhookURL) { } public String getWebhookURL() { - return webhookURL; + return this.webhookURL; } public String getTitle() { - return title; + return this.title; } @DataBoundSetter public void setTitle(String title) { - this.title = title; + this.title = StringUtils.stripToNull(title); } public String getLink() { - return link; + return this.link; } @DataBoundSetter public void setLink(String link) { - this.link = link; + this.link = StringUtils.stripToNull(link); } public String getDescription() { - return description; + return this.description; } @DataBoundSetter public void setDescription(String description) { - this.description = description; + this.description = StringUtils.stripToNull(description); } public String getFooter() { - return footer; + return this.footer; } @DataBoundSetter public void setFooter(String footer) { - this.footer = footer; + this.footer = StringUtils.stripToNull(footer); } public boolean isSuccessful() { - return successful; + return this.successful; } @DataBoundSetter @@ -103,7 +95,7 @@ public void setSuccessful(boolean successful) { } public boolean isUnstable() { - return unstable; + return this.unstable; } @DataBoundSetter @@ -111,67 +103,71 @@ public void setUnstable(boolean unstable) { this.unstable = unstable; } + public String getImage() { + return this.image; + } + @DataBoundSetter public void setImage(String url) { - this.image = url; + this.image = StringUtils.stripToNull(url); } - public String getImage() { - return image; + public String getThumbnail() { + return this.thumbnail; } @DataBoundSetter public void setThumbnail(String url) { - this.thumbnail = url; + this.thumbnail = StringUtils.stripToNull(url); } - public String getThumbnail() { - return thumbnail; + public String getResult() { + return this.result; } @DataBoundSetter public void setResult(String result) { - this.result = result; + this.result = StringUtils.stripToNull(result); } - public String getResult() { - return result; + public String getNotes() { + return this.notes; } @DataBoundSetter public void setNotes(String notes) { - this.notes = notes; + this.notes = StringUtils.stripToNull(notes); } - public String getNotes() { - return notes; + public String getCustomAvatarUrl() { + return this.customAvatarUrl; } @DataBoundSetter public void setCustomAvatarUrl(String customAvatarUrl) { - this.customAvatarUrl = customAvatarUrl; + this.customAvatarUrl = StringUtils.stripToNull(customAvatarUrl); } - public String getCustomAvatarUrl() { - return customAvatarUrl; + public String getCustomUsername() { + return this.customUsername; } @DataBoundSetter public void setCustomUsername(String customUsername) { - this.customUsername = customUsername; + this.customUsername = StringUtils.stripToNull(customUsername); } - public String getCustomUsername() { - return customUsername; + public String getCustomFile() { + return this.customFile; } @DataBoundSetter public void setCustomFile(String customFile) { - this.customFile = customFile; + this.customFile = StringUtils.stripToNull(customFile); } - public String getCustomFile() { - return customFile; + public boolean getEnableArtifactsList() { + return this.enableArtifactsList; } @DataBoundSetter @@ -179,169 +175,94 @@ public void setEnableArtifactsList(boolean enable) { this.enableArtifactsList = enable; } - public boolean getEnableArtifactsList() { - return enableArtifactsList; + public boolean getPrettyArtifactsList() { + return this.prettyArtifactsList; } @DataBoundSetter - public void setShowChangeset(boolean show) { - this.showChangeset = show; + public void setPrettyArtifactsList(boolean enable) { + this.prettyArtifactsList = enable; } public boolean getShowChangeset() { - return showChangeset; + return this.showChangeset; } @DataBoundSetter - public void setScmWebUrl(String url) { - this.scmWebUrl = url; + public void setShowChangeset(boolean show) { + this.showChangeset = show; } public String getScmWebUrl() { - return scmWebUrl; + return this.scmWebUrl; } @DataBoundSetter - public void setDynamicFieldContainer(String fieldsString) { - this.dynamicFieldContainer = DynamicFieldContainer.of(fieldsString); + public void setScmWebUrl(String url) { + this.scmWebUrl = StringUtils.stripToNull(url); } public String getDynamicFieldContainer() { - if(dynamicFieldContainer == null){ + if (this.dynamicFieldContainer == null) { return ""; } - return dynamicFieldContainer.toString(); + return this.dynamicFieldContainer.toString(); } - public static class DiscordPipelineStepExecution extends AbstractSynchronousNonBlockingStepExecution { - - @Inject - transient DiscordPipelineStep step; - - @StepContextParameter - transient TaskListener listener; - - @Override - protected Void run() throws Exception { - listener.getLogger().println("Sending notification to Discord."); - - DiscordWebhook.StatusColor statusColor; - statusColor = StatusColor.YELLOW; - if (step.getResult() == null) { - if (step.isSuccessful()) statusColor = DiscordWebhook.StatusColor.GREEN; - if (step.isSuccessful() && step.isUnstable()) statusColor = DiscordWebhook.StatusColor.YELLOW; - if (!step.isSuccessful() && !step.isUnstable()) statusColor = DiscordWebhook.StatusColor.RED; - } else if (step.getResult().equals(Result.SUCCESS.toString())) { - statusColor = StatusColor.GREEN; - } else if (step.getResult().equals(Result.UNSTABLE.toString())) { - statusColor = StatusColor.YELLOW; - } else if (step.getResult().equals(Result.FAILURE.toString())) { - statusColor = StatusColor.RED; - } else if (step.getResult().equals(Result.ABORTED.toString())) { - statusColor = StatusColor.GREY; - } else { - listener.getLogger().println(step.getResult() + " is not a valid result"); - } + @DataBoundSetter + public void setDynamicFieldContainer(String fieldsString) { + this.dynamicFieldContainer = DynamicFieldContainer.of(fieldsString); + } - DiscordWebhook wh = new DiscordWebhook(step.getWebhookURL()); - wh.setTitle(checkLimitAndTruncate("title", step.getTitle(), TITLE_LIMIT)); - wh.setURL(step.getLink()); - wh.setThumbnail(step.getThumbnail()); - - if (step.getEnableArtifactsList() || step.getShowChangeset()) { - JenkinsLocationConfiguration globalConfig = JenkinsLocationConfiguration.get(); - Run build = getContext().get(Run.class); - wh.setDescription(new EmbedDescription( - build, - globalConfig, - step.getDescription(), - step.getEnableArtifactsList(), - step.getShowChangeset(), - step.getScmWebUrl() - ).toString() - ); - } else { - wh.setDescription(checkLimitAndTruncate("description", step.getDescription(), DESCRIPTION_LIMIT)); - } + public DynamicFieldContainer getActualDynamicFieldContainer() { + return this.dynamicFieldContainer; + } - wh.setImage(step.getImage()); - wh.setFooter(checkLimitAndTruncate("footer", step.getFooter(), FOOTER_LIMIT)); - wh.setStatus(statusColor); - wh.setContent(step.getNotes()); + @Override + public StepExecution start(StepContext context) throws Exception { + return super.start(context); + } - if (step.getCustomAvatarUrl() != null) { - wh.setCustomAvatarUrl(step.getCustomAvatarUrl()); - } + public static class DiscordPipelineStepExecution extends SynchronousNonBlockingStepExecution { + private static final long serialVersionUID = 1L; + private final transient DiscordPipelineStep step; - if (step.getCustomUsername() != null) { - wh.setCustomUsername(step.getCustomUsername()); - } + protected DiscordPipelineStepExecution(DiscordPipelineStep step, StepContext context) { + super(context); + this.step = step; + } - if (step.getCustomFile() != null) { - InputStream fis = getFileInputStream(step.getCustomFile()); - wh.setFile(fis, step.getCustomFile()); - } + @Override + protected Void run() throws Exception { + TaskListener listener = getContext().get(TaskListener.class); + Run build = getContext().get(Run.class); - // Add all key value field pairs to the webhook - addDynamicFieldsToWebhook(wh); + listener.getLogger().println("Sending notification to Discord."); - try { - wh.send(); - } catch (WebhookException e) { + WebhookMessage message = EmbedUtil.createEmbed( + build, + JenkinsLocationConfiguration.get(), + this.step, + getContext(), + listener + ); + + try (WebhookClient client = WebhookClient.withUrl(this.step.getWebhookURL())) { + listener.getLogger().println("Sending notification to Discord."); + client.send(message).get(); + } catch (Exception e) { e.printStackTrace(listener.getLogger()); } return null; } - - private InputStream getFileInputStream(String file) throws IOException, InterruptedException { - FilePath ws = getContext().get(FilePath.class); - if (ws == null) { - throw new IllegalStateException("Could not acquire FilePath"); - } - final FilePath fp = ws.child(file); - if (fp.exists()) { - try { - return fp.read(); - } catch (InvalidPathException var3) { - throw new IOException(var3); - } - } else { - String message = "No such file: " + file; - return new ByteArrayInputStream(message.getBytes(Charset.defaultCharset())); - } - } - - /** - * Add all key value field pairs to the webhook - */ - private void addDynamicFieldsToWebhook(DiscordWebhook wh){ - // Early exit if we don't have any dynamicFieldContainer set - if(step.dynamicFieldContainer == null){ - return; - } - // Go through all fields and add them to the webhook - step.dynamicFieldContainer.getFields().forEach(pair -> wh.addField(pair.getKey(), pair.getValue())); - } - - private String checkLimitAndTruncate(String fieldName, String value, int limit) { - if (value == null) return ""; - if (value.length() > limit) { - listener.getLogger().printf("Warning: '%s' field has more than %d characters (%d). It will be truncated.%n", - fieldName, - limit, - value.length()); - return value.substring(0, limit); - } - return value; - } - - private static final long serialVersionUID = 1L; } @Extension public static class DescriptorImpl extends AbstractStepDescriptorImpl { - public DescriptorImpl() { super(DiscordPipelineStepExecution.class); } + public DescriptorImpl() { + super(DiscordPipelineStepExecution.class); + } @Override public String getFunctionName() { diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/DiscordWebhook.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/DiscordWebhook.java deleted file mode 100644 index 58d953a..0000000 --- a/src/main/java/nz/co/jammehcow/jenkinsdiscord/DiscordWebhook.java +++ /dev/null @@ -1,229 +0,0 @@ -package nz.co.jammehcow.jenkinsdiscord; - -import jenkins.model.Jenkins; -import kong.unirest.HttpResponse; -import kong.unirest.JsonNode; -import kong.unirest.Proxy; -import kong.unirest.Unirest; -import kong.unirest.UnirestException; -import nz.co.jammehcow.jenkinsdiscord.exception.WebhookException; -import org.json.JSONArray; -import org.json.JSONObject; - -import java.io.InputStream; - -/** - * Author: jammehcow. - * Date: 22/04/17. - */ -class DiscordWebhook { - private String webhookUrl; - private JSONObject obj; - private JSONObject embed; - private JSONArray fields; - private InputStream file; - private String filename; - - static final int TITLE_LIMIT = 256; - static final int DESCRIPTION_LIMIT = 2048; - static final int FOOTER_LIMIT = 2048; - - enum StatusColor { - /** - * Green "you're sweet as" color. - */ - GREEN(1681177), - /** - * Yellow "go, but I'm watching you" color. - */ - YELLOW(16776970), - /** - * Red "something ain't right" color. - */ - RED(11278871), - /** - * Grey. Just grey. - */ - GREY(13487565); - private long code; - - StatusColor(int code) { - this.code = code; - } - } - - /** - * Instantiates a new Discord webhook. - * - * @param url the webhook URL - */ - DiscordWebhook(String url) { - this.webhookUrl = url; - this.obj = new JSONObject(); - this.obj.put("username", "Jenkins"); - this.obj.put("avatar_url", "https://get.jenkins.io/art/jenkins-logo/1024x1024/headshot.png"); - this.embed = new JSONObject(); - this.fields = new JSONArray(); - } - - /** - * Sets the embed title. - * - * @param title the title text - * @return this - */ - public DiscordWebhook setTitle(String title) { - this.embed.put("title", title); - return this; - } - - public DiscordWebhook setCustomUsername(String username) { - if (username != null && !username.isEmpty()) - this.obj.put("username", username); - else { - // unset will allow default discord username to be used (as specified in discord's integration settings) - this.obj.remove("username"); - } - return this; - } - - public DiscordWebhook setCustomAvatarUrl(String url) { - if (url != null && !url.isEmpty()) - this.obj.put("avatar_url", url); - else { - // unset will allow default avatar to be used (as specified in discord's integration settings) - this.obj.remove("avatar_url"); - } - return this; - } - - /** - * Sets the embed title url. - * - * @param buildUrl the build url - * @return this - */ - public DiscordWebhook setURL(String buildUrl) { - this.embed.put("url", buildUrl); - return this; - } - - /** - * Sets the build status (for the embed's color). - * - * @param isSuccess if the build is successful - * @return this - */ - public DiscordWebhook setStatus(StatusColor isSuccess) { - this.embed.put("color", isSuccess.code); - return this; - } - - /** - * Sets the embed description. - * - * @param content the content - * @return this - */ - public DiscordWebhook setDescription(String content) { - this.embed.put("description", content); - return this; - } - - public DiscordWebhook setContent(String content) { - this.obj.put("content", content); - return this; - } - - /** - * Sets the URL of image at the bottom of embed. - * - * @param url URL of image - * @return this - */ - public DiscordWebhook setImage(String url) { - JSONObject image = new JSONObject(); - image.put("url", url); - this.embed.put("image", image); - return this; - } - - /** - * Sets the URL of image on the right side. - * - * @param url URL of image - * @return this - */ - public DiscordWebhook setThumbnail(String url) { - JSONObject thumbnail = new JSONObject(); - thumbnail.put("url", url); - this.embed.put("thumbnail", thumbnail); - return this; - } - - public DiscordWebhook addField(String name, String value) { - JSONObject field = new JSONObject(); - field.put("name", name); - field.put("value", value); - this.fields.put(field); - return this; - } - - /** - * Sets the embed's footer text. - * - * @param text the footer text - * @return this - */ - public DiscordWebhook setFooter(String text) { - this.embed.put("footer", new JSONObject().put("text", text)); - return this; - } - - DiscordWebhook setFile(InputStream is, String filename) { - this.file = is; - this.filename = filename; - return this; - } - - /** - * Send the payload to Discord. - * - * @throws WebhookException the webhook exception - */ - public void send() throws WebhookException { - this.embed.put("fields", fields); - if (this.embed.toString().length() > 6000) - throw new WebhookException("Embed object larger than the limit (" + this.embed.toString().length() + ">6000)."); - - this.obj.put("embeds", new JSONArray().put(this.embed)); - - try { - final Jenkins instance = Jenkins.getInstanceOrNull(); - if (instance != null && instance.proxy != null && !Unirest.config().isRunning()) { - String proxyIP = instance.proxy.name; - int proxyPort = instance.proxy.port; - if (!proxyIP.equals("")) { - Unirest.config().proxy(new Proxy(proxyIP, proxyPort)); - } - } - HttpResponse response; - if (file != null) { - response = Unirest.post(this.webhookUrl) - .field("payload_json", obj.toString()) - .field("file", file, filename) - .asJson(); - } else { - response = Unirest.post(this.webhookUrl) - .field("payload_json", obj.toString()) - .asJson(); - } - - if (response.getStatus() < 200 || response.getStatus() >= 300) { - throw new WebhookException(response.getBody().getObject().toString(2)); - } - } catch (UnirestException e) { - e.printStackTrace(); - } - } -} diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/StatusColor.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/StatusColor.java new file mode 100644 index 0000000..3e68a52 --- /dev/null +++ b/src/main/java/nz/co/jammehcow/jenkinsdiscord/StatusColor.java @@ -0,0 +1,30 @@ +package nz.co.jammehcow.jenkinsdiscord; + +public enum StatusColor { + /** + * Green "you're sweet as" color. + */ + GREEN(1681177), + /** + * Yellow "go, but I'm watching you" color. + */ + YELLOW(16776970), + /** + * Red "something ain't right" color. + */ + RED(11278871), + /** + * Grey. Just grey. + */ + GREY(13487565); + + private final int code; + + StatusColor(int code) { + this.code = code; + } + + public int getCode() { + return this.code; + } +} diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/WebhookPublisher.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/WebhookPublisher.java index cfb6fda..ff256e5 100644 --- a/src/main/java/nz/co/jammehcow/jenkinsdiscord/WebhookPublisher.java +++ b/src/main/java/nz/co/jammehcow/jenkinsdiscord/WebhookPublisher.java @@ -1,12 +1,14 @@ package nz.co.jammehcow.jenkinsdiscord; +import club.minnced.discord.webhook.WebhookClient; +import club.minnced.discord.webhook.WebhookClientBuilder; +import club.minnced.discord.webhook.send.WebhookMessage; import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; import hudson.EnvVars; import hudson.Extension; import hudson.Launcher; import hudson.Plugin; -import hudson.PluginWrapper; import hudson.matrix.MatrixConfiguration; import hudson.model.AbstractBuild; import hudson.model.AbstractProject; @@ -17,18 +19,17 @@ import hudson.tasks.Notifier; import hudson.tasks.Publisher; import hudson.util.FormValidation; -import java.io.IOException; -import java.util.Locale; -import java.util.Map; - import jenkins.model.Jenkins; import jenkins.model.JenkinsLocationConfiguration; -import nz.co.jammehcow.jenkinsdiscord.exception.WebhookException; -import nz.co.jammehcow.jenkinsdiscord.util.EmbedDescription; +import nz.co.jammehcow.jenkinsdiscord.util.EmbedUtil; +import org.apache.commons.lang.StringUtils; import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.DataBoundSetter; import org.kohsuke.stapler.QueryParameter; +import java.io.IOException; +import java.util.regex.Matcher; + /** * Author: jammehcow. * Date: 22/04/17. @@ -36,6 +37,8 @@ @SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE", justification = "Requires triage") public class WebhookPublisher extends Notifier { + private static final String NAME = "Discord Notifier"; + private static final String SHORT_NAME = "discord-notifier"; private final String webhookURL; private final String branchName; private final String statusTitle; @@ -43,18 +46,16 @@ public class WebhookPublisher extends Notifier { private final String notes; private final String customAvatarUrl; private final String customUsername; - private DynamicFieldContainer dynamicFieldContainer; private final boolean sendOnStateChange; private final boolean sendOnlyFailed; - private boolean enableUrlLinking; private final boolean enableArtifactList; private final boolean enableFooterInfo; - private boolean showChangeset; - private boolean sendLogFile; - private boolean sendStartNotification; - private static final String NAME = "Discord Notifier"; - private static final String SHORT_NAME = "discord-notifier"; + private final boolean showChangeset; + private final boolean sendLogFile; + private final boolean sendStartNotification; private final String scmWebUrl; + private DynamicFieldContainer dynamicFieldContainer; + private boolean enableUrlLinking; @DataBoundConstructor public WebhookPublisher( @@ -94,6 +95,11 @@ public WebhookPublisher( this.scmWebUrl = scmWebUrl; } + private static String getMarkdownHyperlink(String content, String url) { + url = url.replaceAll("\\)", "\\\\\\)"); + return "[" + content + "](" + url + ")"; + } + public String getWebhookURL() { return this.webhookURL; } @@ -114,18 +120,6 @@ public String getCustomUsername() { return this.customUsername; } - @DataBoundSetter - public void setDynamicFieldContainer(String fieldsString) { - this.dynamicFieldContainer = DynamicFieldContainer.of(fieldsString); - } - - public String getDynamicFieldContainer() { - if(dynamicFieldContainer == null){ - return ""; - } - return dynamicFieldContainer.toString(); - } - public String getNotes() { return this.notes; } @@ -142,7 +136,6 @@ public boolean isSendOnlyFailed() { return this.sendOnlyFailed; } - public boolean isEnableUrlLinking() { return this.enableUrlLinking; } @@ -176,52 +169,84 @@ public boolean needsToRunAfterFinalized() { return true; } + public String getDynamicFieldContainer() { + if (this.dynamicFieldContainer == null) { + return ""; + } + return this.dynamicFieldContainer.toString(); + } + + @DataBoundSetter + public void setDynamicFieldContainer(String fieldsString) { + this.dynamicFieldContainer = DynamicFieldContainer.of(fieldsString); + } + @Override public boolean prebuild(AbstractBuild build, BuildListener listener) { + listener.getLogger().println(this.sendStartNotification); + if (!this.sendStartNotification) + return true; + final EnvVars env; - listener.getLogger().println(sendStartNotification); - if (sendStartNotification) { - try { - env = build.getEnvironment(listener); - DiscordWebhook wh = new DiscordWebhook(env.expand(this.webhookURL)); - AbstractProject project = build.getProject(); - String description; - JenkinsLocationConfiguration globalConfig = JenkinsLocationConfiguration.get(); - wh.setStatus(DiscordWebhook.StatusColor.GREEN); - if (this.statusTitle != null && !this.statusTitle.isEmpty()) { - wh.setTitle("Build started: " + env.expand(this.statusTitle)); - } else { - wh.setTitle("Build started: " + project.getDisplayName() + " #" + build.getId()); - } - String branchNameString = ""; - if (branchName != null && !branchName.isEmpty()) { - branchNameString = "**Branch:** " + env.expand(branchName) + "\n"; - } - if (this.enableUrlLinking) { - String url = globalConfig.getUrl() + build.getUrl(); - description = branchNameString - + "**Build:** " - + getMarkdownHyperlink(build.getId(), url); - wh.setURL(url); - } else { - description = branchNameString - + "**Build:** " - + build.getId(); - } - wh.setDescription(new EmbedDescription(build, globalConfig, description, false, false, null).toString()); - - addDynamicFieldsToWebhook(dynamicFieldContainer, wh, env); - - // Send the webhook - wh.send(); - } catch (WebhookException | InterruptedException | IOException e1) { - e1.printStackTrace(listener.getLogger()); - } + try { + env = build.getEnvironment(listener); + } catch (IOException | InterruptedException e) { + e.printStackTrace(listener.getLogger()); + return false; } + + String title; + if (this.statusTitle != null && !this.statusTitle.isEmpty()) + title = String.format("Build started: %s", env.expand(this.statusTitle)); + else + title = String.format("Build started: %s #%s", build.getProject().getDisplayName(), build.getId()); + + String branch = null; + if (this.branchName != null && !this.branchName.isEmpty()) + branch = env.expand(this.branchName); + + String webhookNotes = null; + if (this.notes != null && !this.notes.isEmpty()) + webhookNotes = env.expand(this.notes); + + WebhookMessage message = EmbedUtil.createEmbed( + build, + JenkinsLocationConfiguration.get(), + listener, + title, + null, + null, + this.enableFooterInfo + ? String.format("Jenkins v%s, %s v%s", build.getHudsonVersion(), getDescriptor().getDisplayName(), getDescriptor().getPluginVersion()) + : null, + null, + this.thumbnailURL.isEmpty() ? null : this.thumbnailURL, + StatusColor.GREEN, + false, + null, + this.enableUrlLinking, + branch, + webhookNotes, + StringUtils.stripToNull(this.customAvatarUrl), + StringUtils.stripToNull(this.customUsername), + null, + null, + this.dynamicFieldContainer, + false, + false, + null + ); + + try (WebhookClient client = WebhookClient.withUrl(this.webhookURL)) { + listener.getLogger().println("Sending notification to Discord."); + client.send(message).get(); + } catch (Exception e) { + e.printStackTrace(listener.getLogger()); + } + return true; } - //TODO clean this function @Override public boolean perform(AbstractBuild build, Launcher launcher, BuildListener listener) throws IOException, InterruptedException { final EnvVars env = build.getEnvironment(listener); @@ -232,9 +257,6 @@ public boolean perform(AbstractBuild build, Launcher launcher, BuildListener lis return true; } - // Create a new webhook payload - DiscordWebhook wh = new DiscordWebhook(env.expand(this.webhookURL)); - if (this.webhookURL.isEmpty()) { // Stop the plugin from continuing when the webhook URL isn't set. Shouldn't happen due to form validation listener.getLogger().println("The Discord webhook is not set!"); @@ -247,116 +269,84 @@ public boolean perform(AbstractBuild build, Launcher launcher, BuildListener lis this.enableUrlLinking = false; } - if (this.sendOnStateChange) { - if (build.getPreviousBuild() != null && build.getResult().equals(build.getPreviousBuild().getResult())) { - // Stops the webhook payload being created if the status is the same as the previous - return true; - } - } - - if (this.sendOnlyFailed) { - if (!build.getResult().equals(Result.FAILURE)) { - return true; - } + if (this.sendOnStateChange && build.getPreviousBuild() != null && build.getResult().equals(build.getPreviousBuild().getResult())) { + // Stops the webhook payload being created if the status is the same as the previous + return true; } - if (this.sendLogFile) { - wh.setFile(build.getLogInputStream(), "build" + build.getNumber() + ".log"); + if (this.sendOnlyFailed && !build.getResult().equals(Result.FAILURE)) { + return true; } - DiscordWebhook.StatusColor statusColor = DiscordWebhook.StatusColor.GREEN; Result buildresult = build.getResult(); - if (!buildresult.isCompleteBuild()) return true; - if (buildresult.isBetterOrEqualTo(Result.SUCCESS)) statusColor = DiscordWebhook.StatusColor.GREEN; - if (buildresult.isWorseThan(Result.SUCCESS)) statusColor = DiscordWebhook.StatusColor.YELLOW; - if (buildresult.isWorseThan(Result.UNSTABLE)) statusColor = DiscordWebhook.StatusColor.RED; - - AbstractProject project = build.getProject(); - StringBuilder combinationString = new StringBuilder(); - if (this.statusTitle != null && !this.statusTitle.isEmpty()) { - wh.setTitle(env.expand(this.statusTitle)); - } else { - wh.setTitle(project.getDisplayName() + " #" + build.getId()); - } - - //Check if MatrixConfiguration - if (project instanceof MatrixConfiguration) { - wh.setTitle(project.getParent().getDisplayName() + " #" + build.getId()); - combinationString.append("**Configuration matrix:**\n"); - for (Map.Entry e : ((MatrixConfiguration) project).getCombination().entrySet()) - combinationString.append(" - ").append(e.getKey()).append(": ").append(e.getValue()).append("\n"); - } - - String branchNameString = ""; - if (branchName != null && !branchName.isEmpty()) { - branchNameString = "**Branch:** " + env.expand(branchName) + "\n"; - } - - String descriptionPrefix; - // Adds links to the description and title if enableUrlLinking is enabled - if (this.enableUrlLinking) { - String url = globalConfig.getUrl() + build.getUrl(); - descriptionPrefix = branchNameString - + "**Build:** " - + getMarkdownHyperlink(build.getId(), url) - + "\n**Status:** " - + getMarkdownHyperlink(build.getResult().toString().toLowerCase(Locale.ENGLISH), url) + "\n"; - wh.setURL(url); - } else { - descriptionPrefix = branchNameString - + "**Build:** " - + build.getId() - + "\n**Status:** " - + build.getResult().toString().toLowerCase(Locale.ENGLISH) + "\n"; - } - descriptionPrefix += combinationString; - - if (notes != null && !notes.isEmpty()) { - wh.setContent(env.expand(notes)); - } - - if (customAvatarUrl != null && !customAvatarUrl.isEmpty()) { - wh.setCustomAvatarUrl(customAvatarUrl); - } - - if (customUsername != null && !customUsername.isEmpty()) { - wh.setCustomUsername(customUsername); + StatusColor statusColor = StatusColor.GREEN; + if (!buildresult.isCompleteBuild()) + return true; + if (buildresult.isBetterOrEqualTo(Result.SUCCESS)) + statusColor = StatusColor.GREEN; + if (buildresult.isWorseThan(Result.SUCCESS)) + statusColor = StatusColor.YELLOW; + if (buildresult.isWorseThan(Result.UNSTABLE)) + statusColor = StatusColor.RED; + + String title; + if (this.statusTitle != null && !this.statusTitle.isEmpty()) + title = env.expand(this.statusTitle); + else + title = String.format("%s #%s", build.getProject().getDisplayName(), build.getId()); + + MatrixConfiguration matrixConfiguration = null; + if (build.getProject() instanceof MatrixConfiguration) { + title = String.format("%s #%s", build.getProject().getParent().getDisplayName(), build.getId()); + matrixConfiguration = (MatrixConfiguration) build.getProject(); } - wh.setThumbnail(thumbnailURL); - wh.setDescription( - new EmbedDescription(build, globalConfig, descriptionPrefix, this.enableArtifactList, this.showChangeset, this.scmWebUrl) - .toString() + String branch = null; + if (this.branchName != null && !this.branchName.isEmpty()) + branch = env.expand(this.branchName); + + String webhookNotes = null; + if (this.notes != null && !this.notes.isEmpty()) + webhookNotes = env.expand(this.notes); + + WebhookMessage message = EmbedUtil.createEmbed( + build, + globalConfig, + listener, + title, + this.enableUrlLinking ? globalConfig.getUrl() + build.getUrl() : null, + null, + this.enableFooterInfo + ? String.format("Jenkins v%s, %s v%s", build.getHudsonVersion(), getDescriptor().getDisplayName(), getDescriptor().getPluginVersion()) + : null, + null, + this.thumbnailURL.isEmpty() ? null : this.thumbnailURL, + statusColor, + true, + matrixConfiguration, + this.enableUrlLinking, + branch, + webhookNotes, + StringUtils.stripToNull(this.customAvatarUrl), + StringUtils.stripToNull(this.customUsername), + this.sendLogFile ? String.format("build-%d.log", build.getNumber()) : null, + this.sendLogFile ? build.getLogInputStream() : null, + this.dynamicFieldContainer, + this.enableArtifactList, + this.showChangeset, + StringUtils.stripToNull(this.scmWebUrl) ); - addDynamicFieldsToWebhook(dynamicFieldContainer, wh, env); - wh.setStatus(statusColor); - - if (this.enableFooterInfo) - wh.setFooter("Jenkins v" + build.getHudsonVersion() + ", " + getDescriptor().getDisplayName() + " v" + getDescriptor().getPluginVersion()); - - try { + try (WebhookClient client = WebhookClient.withUrl(this.webhookURL)) { listener.getLogger().println("Sending notification to Discord."); - wh.send(); - } catch (WebhookException e) { + client.send(message).get(); + } catch (Exception e) { e.printStackTrace(listener.getLogger()); } return true; } - /** - * Add all key value field pairs to the webhook - */ - private void addDynamicFieldsToWebhook(DynamicFieldContainer dynamicFieldContainer, DiscordWebhook wh, EnvVars env){ - // Early exit if we don't have any dynamicFieldContainer set - if(dynamicFieldContainer == null){ - return; - } - // Go through all fields and add them to the webhook - dynamicFieldContainer.getFields().forEach(pair -> wh.addField(pair.getKey() + ":", env.expand(pair.getValue()))); - } - public BuildStepMonitor getRequiredMonitorService() { return BuildStepMonitor.NONE; } @@ -368,38 +358,34 @@ public DescriptorImpl getDescriptor() { @Extension public static final class DescriptorImpl extends BuildStepDescriptor { + private final Plugin plugin = Jenkins.get().getPlugin(SHORT_NAME); + public boolean isApplicable(Class aClass) { return true; } - final Plugin p = Jenkins.get().getPlugin(SHORT_NAME); - public FormValidation doCheckWebhookURL(@QueryParameter String value) { - if (!value.matches("https://(canary\\.|ptb\\.|)discord(app)*\\.com/api/webhooks/\\d{18,19}/(\\w|-|_)*(/?)")) + Matcher matcher = WebhookClientBuilder.WEBHOOK_PATTERN.matcher(value); + if (!matcher.matches()) return FormValidation.error("Please enter a valid Discord webhook URL."); return FormValidation.ok(); } @NonNull public String getDisplayName() { - if (p == null) { + if (this.plugin == null) { return NAME; } else { - return p.getWrapper().getDisplayName(); + return this.plugin.getWrapper().getDisplayName(); } } public String getPluginVersion() { - if (p == null) { + if (this.plugin == null) { return ""; } else { - return p.getWrapper().getVersion(); + return this.plugin.getWrapper().getVersion(); } } } - - private static String getMarkdownHyperlink(String content, String url) { - url = url.replaceAll("\\)", "\\\\\\)"); - return "[" + content + "](" + url + ")"; - } } diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/exception/WebhookException.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/exception/WebhookException.java deleted file mode 100644 index 380186c..0000000 --- a/src/main/java/nz/co/jammehcow/jenkinsdiscord/exception/WebhookException.java +++ /dev/null @@ -1,15 +0,0 @@ -package nz.co.jammehcow.jenkinsdiscord.exception; - -/** - * @author jammehcow - */ - -public class WebhookException extends Exception { - public WebhookException() { super(); } - - public WebhookException(String message) { super(message); } - - public WebhookException(String message, Throwable cause) { super(message, cause); } - - public WebhookException(Throwable cause) { super(cause); } -} diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/EmbedDescription.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/EmbedDescription.java deleted file mode 100644 index 269f765..0000000 --- a/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/EmbedDescription.java +++ /dev/null @@ -1,148 +0,0 @@ -package nz.co.jammehcow.jenkinsdiscord.util; - -import jenkins.scm.RunWithSCM; -import hudson.model.Run; -import hudson.scm.ChangeLogSet; - -import java.util.ArrayList; -import java.util.Arrays; - -import jenkins.model.JenkinsLocationConfiguration; - -import java.util.LinkedList; -import java.util.List; - -import org.apache.commons.lang.StringUtils; - -/** - * @author jammehcow - */ - -public class EmbedDescription { - private static final int maxEmbedStringLength = 2048; // The maximum length of an embed description. - - private LinkedList changesList = new LinkedList<>(); - private LinkedList artifactsList = new LinkedList<>(); - - private String prefix; - private String finalDescription; - - public EmbedDescription( - Run build, - JenkinsLocationConfiguration globalConfig, - String prefix, - boolean enableArtifactsList, - boolean showChangeset, - String scmWebUrl - ) { - String artifactsURL = globalConfig.getUrl() + build.getUrl() + "artifact/"; - this.prefix = StringUtils.trimToNull(prefix); - - if (showChangeset) { - ArrayList changes = new ArrayList<>(); - List> changeSets = ((RunWithSCM) build).getChangeSets(); - for (ChangeLogSet i : changeSets) - changes.addAll(Arrays.asList(i.getItems())); - if (changes.isEmpty()) { - this.changesList.add("\n*No changes.*\n"); - } else { - this.changesList.add("\n**Changes:**\n"); - - boolean withLinks; - try { - String dummy = String.format(scmWebUrl, ""); - withLinks = true; - } catch (Exception ex) { - withLinks = false; - } - - for (Object o : changes) { - ChangeLogSet.Entry entry = (ChangeLogSet.Entry) o; - - String commitID = entry.getCommitId(); - String commitDisplayStr; - if (commitID == null) commitDisplayStr = "null "; - else if (commitID.length() < 6) commitDisplayStr = commitID; - else commitDisplayStr = commitID.substring(0, 6); - - String msg = entry.getMsg().trim(); - int nl = msg.indexOf("\n"); - if (nl >= 0) - msg = msg.substring(0, nl).trim(); - msg = escapeMarkdown(msg); - - String author = entry.getAuthor().getFullName(); - - if (withLinks) { - String url = String.format(scmWebUrl, commitID); - this.changesList.add(String.format("- [`%s`](%s) *%s - %s*%n", - commitDisplayStr, url, msg, author)); - } else { - this.changesList.add(String.format("- `%s` *%s - %s*%n", - commitDisplayStr, msg, author)); - } - } - } - } - - if (enableArtifactsList) { - this.artifactsList.add("\n**Artifacts:**\n"); - //noinspection unchecked - List artifacts = build.getArtifacts(); - if (artifacts.isEmpty()) { - this.artifactsList.add("\n*No artifacts saved.*"); - } else { - for (Run.Artifact artifact : artifacts) { - this.artifactsList.add("- " + artifactsURL + artifact.getHref() + "\n"); - } - } - } - - while (this.getCurrentDescription().length() > maxEmbedStringLength) { - if (this.changesList.size() > 5) { - // Dwindle the changes list down to 5 changes. - while (this.changesList.size() != 5) this.changesList.removeLast(); - } else if (this.artifactsList.size() > 1) { - this.artifactsList.clear(); - this.artifactsList.add(artifactsURL); - } else { - // Worst case scenario: truncate the description. - this.finalDescription = this.getCurrentDescription().substring(0, maxEmbedStringLength - 1); - return; - } - } - - this.finalDescription = this.getCurrentDescription(); - } - - private String getCurrentDescription() { - StringBuilder description = new StringBuilder(); - if (this.prefix != null) - description.append(this.prefix); - - // Collate the changes and artifacts into the description. - for (String changeEntry : this.changesList) { - description.append(changeEntry); - } - for (String artifact : this.artifactsList) { - description.append(artifact); - } - - return description.toString().trim(); - } - - @Override - public String toString() { - return this.finalDescription; - } - - // https://support.discord.com/hc/en-us/articles/210298617 - private static String escapeMarkdown(String text) { - return text - .replace("\\", "\\\\") - .replace("*", "\\*") - .replace("_", "\\_") - .replace("~", "\\~") - .replace("`", "\\`"); - } -} diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/EmbedUtil.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/EmbedUtil.java new file mode 100644 index 0000000..09bab3b --- /dev/null +++ b/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/EmbedUtil.java @@ -0,0 +1,352 @@ +package nz.co.jammehcow.jenkinsdiscord.util; + +import club.minnced.discord.webhook.send.WebhookEmbed.EmbedAuthor; +import club.minnced.discord.webhook.send.WebhookEmbed.EmbedField; +import club.minnced.discord.webhook.send.WebhookEmbed.EmbedFooter; +import club.minnced.discord.webhook.send.WebhookEmbed.EmbedTitle; +import club.minnced.discord.webhook.send.WebhookEmbedBuilder; +import club.minnced.discord.webhook.send.WebhookMessage; +import club.minnced.discord.webhook.send.WebhookMessageBuilder; +import hudson.FilePath; +import hudson.matrix.MatrixConfiguration; +import hudson.model.Result; +import hudson.model.Run; +import hudson.model.TaskListener; +import hudson.scm.ChangeLogSet; +import jenkins.model.JenkinsLocationConfiguration; +import jenkins.scm.RunWithSCM; +import nz.co.jammehcow.jenkinsdiscord.DiscordPipelineStep; +import nz.co.jammehcow.jenkinsdiscord.DynamicFieldContainer; +import nz.co.jammehcow.jenkinsdiscord.StatusColor; +import org.apache.commons.lang.StringUtils; +import org.jenkinsci.plugins.workflow.steps.StepContext; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.file.InvalidPathException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Deque; +import java.util.LinkedList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Queue; + +public class EmbedUtil { + private static final int MAX_CONTENT_LENGTH = 2000; + private static final int MAX_EMBED_DESCRIPTION_LENGTH = 4096; + private static final int MAX_EMBED_TITLE_LENGTH = 256; + private static final int MAX_EMBED_FIELD_LENGTH = 1024; + private static final int MAX_EMBED_FOOTER_LENGTH = 2048; + + private EmbedUtil() { + } + + public static WebhookMessage createEmbed( + Run build, + JenkinsLocationConfiguration globalConfig, + DiscordPipelineStep step, + StepContext context, + TaskListener listener + ) throws IOException, InterruptedException { + StatusColor statusColor = StatusColor.YELLOW; + if (step.getResult() == null) { + if (step.isSuccessful()) + statusColor = StatusColor.GREEN; + if (step.isSuccessful() && step.isUnstable()) + statusColor = StatusColor.YELLOW; + if (!step.isSuccessful() && !step.isUnstable()) + statusColor = StatusColor.RED; + } else { + Result result; + if (step.getResult() != null) + result = Result.fromString(step.getResult()); + else + result = build.getResult(); + + if (result == null) + throw new IllegalStateException("[Discord Notifier] build.getResult() is null!"); + + if (result.equals(Result.SUCCESS)) { + statusColor = StatusColor.GREEN; + } else if (result.equals(Result.UNSTABLE)) { + statusColor = StatusColor.YELLOW; + } else if (result.equals(Result.FAILURE)) { + statusColor = StatusColor.RED; + } else if (result.equals(Result.ABORTED) || result.equals(Result.NOT_BUILT)) { + statusColor = StatusColor.GREY; + } + } + return EmbedUtil.createEmbed(build, + globalConfig, + listener, + step.getTitle(), + step.getLink(), + step.getDescription(), + step.getFooter(), + step.getImage(), + step.getThumbnail(), + statusColor, + true, + null, + true, + null, + step.getNotes(), + step.getCustomAvatarUrl(), + step.getCustomUsername(), + step.getCustomFile(), + EmbedUtil.getFileInputStream(context, step.getCustomFile()), + step.getActualDynamicFieldContainer(), + step.getEnableArtifactsList(), + step.getShowChangeset(), + step.getScmWebUrl() + ); + } + + public static WebhookMessage createEmbed( + Run build, + JenkinsLocationConfiguration globalConfig, + TaskListener listener, + String title, + String link, + String description, + String footer, + String image, + String thumbnail, + StatusColor statusColor, + boolean showStatus, + MatrixConfiguration matrixConfiguration, + boolean urlLinking, + String branch, + String notes, + String customAvatarUrl, + String customUsername, + String customFile, + InputStream customFileInputStream, + DynamicFieldContainer dynamicFieldContainer, + boolean enableArtifactsList, + boolean showChangeset, + String scmWebUrlTemplate + ) { + String artifactsURL = globalConfig.getUrl() + build.getUrl() + "artifact/"; + + WebhookMessageBuilder messageBuilder = new WebhookMessageBuilder(); + WebhookEmbedBuilder embedBuilder = new WebhookEmbedBuilder(); + + messageBuilder.setContent(EmbedUtil.truncateToLimit(listener, "content", notes, EmbedUtil.MAX_CONTENT_LENGTH)); + + embedBuilder.setTitle(new EmbedTitle(EmbedUtil.truncateToLimit(listener, "title", title, EmbedUtil.MAX_EMBED_TITLE_LENGTH), link)); + + embedBuilder.setDescription(EmbedUtil.truncateToLimit(listener, "description", StringUtils.stripToNull(description), EmbedUtil.MAX_EMBED_DESCRIPTION_LENGTH)); + + if (branch != null) { + embedBuilder.addField(new EmbedField(true, "Branch", branch)); + } + + String buildResult = Objects.requireNonNull(build.getResult()).toString().toLowerCase(Locale.ENGLISH); + if (urlLinking) { + embedBuilder.addField(new EmbedField(true, "Build", MarkdownUtil.formatMarkdownUrl(build.getId(), link))); + if (showStatus) + embedBuilder.addField(new EmbedField(true, "Status", MarkdownUtil.formatMarkdownUrl(buildResult, link))); + } else { + embedBuilder.addField(new EmbedField(true, "Build", build.getId())); + if (showStatus) + embedBuilder.addField(new EmbedField(true, "Status", buildResult)); + } + + if (matrixConfiguration != null) { + StringBuilder descriptionBuilder = new StringBuilder(); + for (Map.Entry entry : matrixConfiguration.getCombination().entrySet()) + descriptionBuilder.append(String.format("- %s: %s", entry.getKey(), entry.getValue())) + .append('\n'); + + String matrixDescription = EmbedUtil.truncateToLimit( + listener, + "matrix configuration", + descriptionBuilder.toString().strip(), + EmbedUtil.MAX_EMBED_FIELD_LENGTH + ); + + embedBuilder.addField(new EmbedField(false, "Configuration matrix", matrixDescription)); + } + + LinkedList changesList = new LinkedList<>(); + if (showChangeset) { + changesList = new LinkedList<>(EmbedUtil.buildChangesetList((RunWithSCM) build, scmWebUrlTemplate)); + } + + LinkedList artifactsList = new LinkedList<>(); + if (enableArtifactsList) { + artifactsList = new LinkedList<>(EmbedUtil.buildArtifactList(build, artifactsURL)); + } + + + if (!changesList.isEmpty()) { + // shorten to at most 1024 characters (max field size) + while (EmbedUtil.getLengthForList(changesList) > EmbedUtil.MAX_EMBED_FIELD_LENGTH) { + changesList.removeLast(); + } + + StringBuilder changesDescription = new StringBuilder(); + for (String changeEntry : changesList) + changesDescription.append(changeEntry) + .append('\n'); + + embedBuilder.addField(new EmbedField(false, "Changes", changesDescription.toString().strip())); + } else if (showChangeset) { + embedBuilder.addField(new EmbedField(false, "Changes", "*No changes.*")); + } + + if (!artifactsList.isEmpty()) { + StringBuilder artifactsDescription = new StringBuilder(); + for (String artifact : artifactsList) + artifactsDescription.append(artifact) + .append('\n'); + + if (artifactsDescription.length() > EmbedUtil.MAX_EMBED_FIELD_LENGTH) + embedBuilder.addField(new EmbedField(false, "Artifacts", artifactsURL)); + else + embedBuilder.addField(new EmbedField(false, "Artifacts", artifactsDescription.toString().strip())); + } else if (enableArtifactsList) { + embedBuilder.addField(new EmbedField(false, "Artifacts", "*No artifacts saved.*")); + } + + embedBuilder.setColor(statusColor.getCode()); + embedBuilder.setImageUrl(image); + embedBuilder.setThumbnailUrl(thumbnail); + + if (footer != null) { + EmbedFooter embedFooter = new EmbedFooter( + EmbedUtil.truncateToLimit(listener, "footer", footer, EmbedUtil.MAX_EMBED_FOOTER_LENGTH), + null + ); + + embedBuilder.setFooter(embedFooter); + } + + String username = EmbedUtil.withFallback(customUsername, "Jenkins"); + String avatar = EmbedUtil.withFallback(customAvatarUrl, "https://get.jenkins.io/art/jenkins-logo/1024x1024/headshot.png"); + embedBuilder.setAuthor(new EmbedAuthor(username, avatar, null)); + + if (dynamicFieldContainer != null) { + dynamicFieldContainer.getFields().forEach(pair -> { + String fieldTitle = EmbedUtil.truncateToLimit(listener, pair.getKey(), pair.getKey(), EmbedUtil.MAX_EMBED_TITLE_LENGTH); + String fieldDescription = EmbedUtil.truncateToLimit(listener, pair.getKey(), pair.getValue(), EmbedUtil.MAX_EMBED_TITLE_LENGTH); + embedBuilder.addField(new EmbedField(false, fieldTitle, fieldDescription)); + }); + } + + if (customFile != null && customFileInputStream != null) + messageBuilder.addFile(customFile, customFileInputStream); + + messageBuilder.addEmbeds(embedBuilder.build()); + + return messageBuilder.build(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + public static List buildArtifactList(Run build, String artifactsURL) { + + List artifacts = build.getArtifacts(); + if (artifacts.isEmpty()) { + return new ArrayList<>(); + } + + List artifactList = new ArrayList<>(); + + for (Run.Artifact artifact : artifacts) + artifactList.add(String.format("- [%s](%s)", null, artifactsURL + artifact.getHref())); + + return artifactList; + } + + @SuppressWarnings({"unchecked", "rawtypes", "SuspiciousArrayCast"}) + public static List buildChangesetList(RunWithSCM build, String scmWebUrl) { + List changes = new ArrayList<>(); + + for (ChangeLogSet changelogset : (List>) build.getChangeSets()) + changes.addAll(Arrays.asList((ChangeLogSet.Entry[]) changelogset.getItems())); + + if (changes.isEmpty()) { + return new ArrayList<>(); + } + + boolean withLinks; + try { + String dummy = String.format(scmWebUrl, ""); + withLinks = true; + } catch (Exception ex) { // null or illegal format specification + withLinks = false; + } + + List changesetList = new ArrayList<>(); + + for (ChangeLogSet.Entry entry : changes) { + String commitID = entry.getCommitId(); + String commitDisplayStr; + if (commitID == null) + commitDisplayStr = "null"; + else + commitDisplayStr = commitID.substring(0, Math.min(commitID.length(), 6)); + + String msg = MarkdownUtil.escape(entry.getMsg().strip().split("\n")[0]); + + String commitAuthor = entry.getAuthor().getFullName(); + + if (withLinks) { + changesetList.add(String.format("- [`%s`](%s) *%s - %s*", commitDisplayStr, String.format(scmWebUrl, commitID), msg, commitAuthor)); + } else { + changesetList.add(String.format("- `%s` *%s - %s*", commitDisplayStr, msg, commitAuthor)); + } + } + + return changesetList; + } + + private static T withFallback(T value, T fallback) { + if (value == null) + return fallback; + return value; + } + + private static int getLengthForList(List list) { + return list.stream().mapToInt(String::length).reduce(0, Integer::sum) + list.size(); + } + + private static String truncateToLimit(TaskListener listener, String fieldName, String value, int limit) { + if (value == null) + return null; + + if (value.length() > limit) { + listener.getLogger().printf("Warning: '%s' field has more than %d characters (%d). It will be truncated.%n", + fieldName, + limit, + value.length()); + return value.substring(0, limit); + } + + return value; + } + + private static InputStream getFileInputStream(StepContext context, String file) throws IOException, InterruptedException { + FilePath ws = context.get(FilePath.class); + if (ws == null) + return null; + + FilePath fp = ws.child(file); + if (fp.exists()) { + try { + return fp.read(); + } catch (InvalidPathException var3) { + throw new IOException(var3); + } + } else { + String message = "No such file: " + file; + return new ByteArrayInputStream(message.getBytes(Charset.defaultCharset())); + } + } +} diff --git a/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/MarkdownUtil.java b/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/MarkdownUtil.java new file mode 100644 index 0000000..bba98fb --- /dev/null +++ b/src/main/java/nz/co/jammehcow/jenkinsdiscord/util/MarkdownUtil.java @@ -0,0 +1,99 @@ +package nz.co.jammehcow.jenkinsdiscord.util; + +import java.util.Objects; + +public final class MarkdownUtil { + private MarkdownUtil() { + } + + /** + * From JDA's MarkdownSanitizer + *

+ * Escapes every single markdown formatting token found in the provided string. + *
Example: {@code escape("**Hello _World_", true)} + *

+ * This code is licensed under the Apache-2.0 license: + *

+     * Copyright 2015 Austin Keener, Michael Ritter, Florian Spieß, and the JDA contributors
+     *
+     * Licensed under the Apache License, Version 2.0 (the "License");
+     * you may not use this file except in compliance with the License.
+     * You may obtain a copy of the License at
+     *
+     *    https://www.apache.org/licenses/LICENSE-2.0
+     *
+     * Unless required by applicable law or agreed to in writing, software
+     * distributed under the License is distributed on an "AS IS" BASIS,
+     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+     * See the License for the specific language governing permissions and
+     * limitations under the License.
+     * 
+ * + * @param sequence The string to sanitize + * @return The string with escaped markdown + * @throws NullPointerException If provided with a null sequence + */ + public static String escape(String sequence) { + Objects.requireNonNull(sequence); + StringBuilder builder = new StringBuilder(); + boolean escaped = false; + boolean newline = true; + for (int i = 0; i < sequence.length(); i++) { + char current = sequence.charAt(i); + if (newline) { + newline = Character.isWhitespace(current); // might still be a quote if prefixed by whitespace + if (current == '>') { + // Check for quote if line starts with angle bracket + if (i + 1 < sequence.length() && Character.isWhitespace(sequence.charAt(i + 1))) { + builder.append("\\>"); // simple quote + } else if (i + 3 < sequence.length() && sequence.startsWith(">>>", i) && + Character.isWhitespace(sequence.charAt(i + 3))) { + builder.append("\\>\\>\\>").append(sequence.charAt(i + 3)); // block quote + i += 3; // since we include 3 angle brackets AND whitespace + } else { + builder.append(current); // just a normal angle bracket + } + continue; + } + } + + if (escaped) { + builder.append(current); + escaped = false; + continue; + } + // Handle average case + switch (current) { + case '*': // simple markdown escapes for single characters + case '_': + case '`': + builder.append('\\').append(current); + break; + case '|': // cases that require at least 2 characters in sequence + case '~': + if (i + 1 < sequence.length() && sequence.charAt(i + 1) == current) { + builder.append('\\').append(current) + .append('\\').append(current); + i++; + } else + builder.append(current); + break; + case '\\': // escape character + builder.append(current); + escaped = true; + break; + case '\n': // linefeed is a special case for quotes + builder.append(current); + newline = true; + break; + default: + builder.append(current); + } + } + return builder.toString(); + } + + public static String formatMarkdownUrl(String text, String url) { + return String.format("[%s](%s)", text, url.replaceAll("\\)", "\\\\\\)")); + } +} diff --git a/src/test/java/nz/co/jammehcow/jenkinsdiscord/BasicTest.java b/src/test/java/nz/co/jammehcow/jenkinsdiscord/BasicTest.java index 446e7c9..e85ae6e 100644 --- a/src/test/java/nz/co/jammehcow/jenkinsdiscord/BasicTest.java +++ b/src/test/java/nz/co/jammehcow/jenkinsdiscord/BasicTest.java @@ -1,35 +1,24 @@ package nz.co.jammehcow.jenkinsdiscord; +import org.jenkinsci.plugins.workflow.steps.StepConfigTester; +import org.junit.Rule; import org.junit.Test; - -import static org.junit.Assert.fail; +import org.jvnet.hudson.test.JenkinsRule; public class BasicTest { - @Test - public void webhookClassDoesntThrow() { - try { - DiscordWebhook wh = new DiscordWebhook("http://exampl.e"); - wh.setContent("content"); - wh.setDescription("desc"); - wh.setStatus(DiscordWebhook.StatusColor.GREEN); - wh.send(); - } catch (Exception e) { - fail(); - } - } + @Rule + public JenkinsRule j = new JenkinsRule(); @Test - public void pipelineDoesntThrow() { + public void configRoundTrip() { try { DiscordPipelineStep step = new DiscordPipelineStep("http://exampl.e"); step.setTitle("Test title"); - DiscordPipelineStep.DiscordPipelineStepExecution execution = - new DiscordPipelineStep.DiscordPipelineStepExecution(); - execution.step = step; - execution.listener = () -> System.out; - execution.run(); + + DiscordPipelineStep roundtrippedStep = new StepConfigTester(this.j).configRoundTrip(step); + this.j.assertEqualDataBoundBeans(step, roundtrippedStep); } catch (Exception e) { - fail(); + throw new RuntimeException(e); } } }