/******************************************************************************** * CruiseControl, a Continuous Integration Toolkit * Copyright (c) 2001-2003, ThoughtWorks, Inc. * 200 E. Randolph, 25th Floor * Chicago, IL 60601 USA * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * * + Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * + Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following * disclaimer in the documentation and/or other materials provided * with the distribution. * * + Neither the name of ThoughtWorks, Inc., CruiseControl, nor the * names of its contributors may be used to endorse or promote * products derived from this software without specific prior * written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ********************************************************************************/ package net.sourceforge.cruisecontrol.publishers; import java.io.File; import java.io.IOException; import java.io.Serializable; import java.io.StringWriter; import java.net.MalformedURLException; import java.net.URL; import java.util.Calendar; import java.util.Collection; import java.util.HashMap; import java.util.Hashtable; import java.util.Iterator; import java.util.Map; import java.util.StringTokenizer; import java.util.Vector; import javax.xml.transform.Transformer; import javax.xml.transform.TransformerException; import javax.xml.transform.TransformerFactory; import javax.xml.transform.stream.StreamResult; import javax.xml.transform.stream.StreamSource; import net.sourceforge.cruisecontrol.CruiseControlException; import net.sourceforge.cruisecontrol.Publisher; import net.sourceforge.cruisecontrol.util.XMLLogHelper; import org.apache.log4j.Logger; import org.apache.xmlrpc.XmlRpcClient; import org.jdom.Element; /** * <p> * Used to publish a blog entry based on the build report using the Blogger API, * MetaWeblog API or the LiveJournal API. * </p> * <p> * Here's a sample of the publisher element to put into your <tt>config.xml</tt>: * </p> * * <pre> * <weblog blogurl="http://yourblogserver:port/blog/xmlrpc" * api="metaweblog" * blogid="yourblog" * username="user1" * password="secret" * category="cruisecontrol" * reportsuccess="fixes" * subjectprefix="[CC]" * buildresultsurl="http://yourbuildserver:port/cc/buildresults" * logdir="/var/cruisecontrol/logs/YourProject" * xsldir="/opt/cruisecontrol/reporting/jsp/xsl" * css="/opt/cruisecontrol/reporting/jsp/css/cruisecontrol.css" * /> * </pre> * * <p> * And you also need to register the 'weblog' task with the following entry if * you're using this task with an older version of CruiseControl which doesn't * have the WeblogPublisher registered by default. * * <pre> * <project name="foo"> * <plugin name="weblog" * classname="net.sourceforge.cruisecontrol.publishers.WeblogPublisher"/> * ... * </project> * </pre> * * @author Lasse Koskela */ public class WeblogPublisher implements Publisher { private static final long serialVersionUID = -34809809594503919L; private static final Logger LOG = Logger.getLogger(WeblogPublisher.class); private static final String APP_KEY = "CruiseControl Blog Publisher"; private static final String DEFAULT_API = "metaweblog"; private static final String DEFAULT_REPORTSUCCESS = "always"; private static final boolean DEFAULT_SPAMWHILEBROKEN = true; // blogging configurations private String blogId; private String api = DEFAULT_API; private String username; private String password; private String category = ""; private String blogUrl; private String buildResultsURL; private String reportSuccess = DEFAULT_REPORTSUCCESS; private boolean spamWhileBroken = DEFAULT_SPAMWHILEBROKEN; private String subjectPrefix; // transformation resources private String xslFile; private String xslDir; private String css; private String logDir; private String[] xslFileNames = { "header.xsl", "maven.xsl", "checkstyle.xsl", "compile.xsl", "javadoc.xsl", "unittests.xsl", "modifications.xsl", "distributables.xsl" }; private static final Map<String, Class> API_CLIENTS = new HashMap<String, Class>(); static { API_CLIENTS.put("metaweblog", MetaWeblogApiClient.class); API_CLIENTS.put("blogger", BloggerApiClient.class); API_CLIENTS.put("livejournal", LiveJournalApiClient.class); } // --- ACCESSORS --- /** * If xslFile is set then both xslDir and css are ignored. Specified xslFile * must take care of entire document -- html open/close, body tags, styles, * etc. */ public void setXSLFile(final String fullPathToXslFile) { xslFile = fullPathToXslFile; } /** * Directory where xsl files are located. */ public void setXSLDir(final String xslDirectory) { xslDir = xslDirectory; } /** * Method to override the default list of file names that will be looked for * in the directory specified by xslDir. By default these are the standard * CruseControl xsl files: <br> * <ul> * <li> header.xsl maven.xsl etc ... * </ul> * I expect this to be used by a derived class to allow someone to change * the order of xsl files or to add/remove one to/from the list or a * combination. * * @param fileNames list of file names that will be looked for * in the directory specified by xslDir */ protected void setXSLFileNames(final String[] fileNames) { if (fileNames == null) { throw new IllegalArgumentException( "xslFileNames can't be null (but can be empty)"); } xslFileNames = fileNames; } /** * Provided as an alternative to setXSLFileNames for changing the list of * files to use. * * @return xsl files to use in generating the email */ protected String[] getXslFileNames() { return xslFileNames; } /** * Path to cruisecontrol.css. Only used with xslDir, not xslFile. */ public void setCSS(final String cssFilename) { css = cssFilename; } /** * Path to the log file as set in the log element of the configuration xml * file. */ public void setLogDir(final String directory) { if (directory == null) { throw new IllegalArgumentException("logDir cannot be null!"); } this.logDir = directory; } /** * The API used for posting to your blog. Currently, acceptable values are * <tt>blogger</tt>,<tt>metaweblog</tt> and <tt>livejournal</tt>. */ public void setApi(final String api) { this.api = api; } /** * The "blog ID" for the blog you're posting to. The value depends on your * particular weblog product. */ public void setBlogId(final String blogId) { this.blogId = blogId; } /** * The URL where your blog's remote API is running at. For example, the * value could look like <tt>http://www.yoursite.com/blog/xmlrpc</tt> or * <tt>http://www.livejournal.com/interface/xmlrpc</tt>. */ public void setBlogUrl(final String blogUrl) { this.blogUrl = blogUrl; } /** * The username to use for authentication. */ public void setUsername(final String username) { this.username = username; } /** * The password to use for authentication. */ public void setPassword(final String password) { this.password = password; } /** * The category to set for the blog entry. When using the MetaWeblogAPI, you * can also use a comma-separated list of several categories. */ public void setCategory(final String category) { this.category = category; } /** * The prefix to be used before the title of the blog entry. If * <tt>null</tt>, no prefix will be used. */ public void setSubjectPrefix(final String prefix) { this.subjectPrefix = prefix; } /** * The base build results URL where your CruiseControl reporting application * is running. For example, <tt>http://buildserver:8080/cc/myproject</tt>. */ public void setBuildResultsURL(final String url) { this.buildResultsURL = url; } /** * The rule for posting a blog entry for successful builds. Accepted values * are <tt>never</tt>,<tt>always</tt> and <tt>fixes</tt>. */ public void setReportSuccess(final String reportSuccess) { this.reportSuccess = reportSuccess; } /** * The rule for posting a blog entry for each subsequent failed build. * Accepted values are <tt>true</tt> and <tt>false</tt>. */ public void setSpamWhileBroken(final boolean spamWhileBroken) { this.spamWhileBroken = spamWhileBroken; } // --- METHODS --- /** * Implementing the <code>Publisher</code> interface. * * @param cruisecontrolLog * The build results XML */ public void publish(Element cruisecontrolLog) { XMLLogHelper helper = new XMLLogHelper(cruisecontrolLog); try { if (shouldSend(helper)) { postBlogEntry(createSubject(helper), createMessage(helper .getProjectName(), helper.getLogFileName())); } else { LOG.debug("shouldSend() indicated we should not" + " post a blog entry at this time"); } } catch (CruiseControlException e) { LOG.error("", e); } } /** * The interface for abstracting away the specific blogging API being used. * * @author Lasse Koskela */ interface BloggingApi extends Serializable { /** * Post a new blog entry. * * @param subject * The blog entry's subject. * @param content * The blog entry's content. * @return The newly created blog entry's identifier. */ public Object newPost(String blogUrl, String blogId, String username, String password, String category, String subject, String content); } /** * A <tt>BloggingApi</tt> implementation for the Blogger API. * * @author Lasse Koskela */ public static class BloggerApiClient implements BloggingApi { private static final long serialVersionUID = 6614787780439141028L; public Object newPost(final String blogUrl, final String blogId, final String username, final String password, final String category, final String subject, String content) { // the Blogger API doesn't support titles for blog entries so // we're using the common (de facto standard) workaround to embed // the title into the content and let the weblog software parse // the title from there, if supported. content = "<title>" + subject + "</title>" + content; Object postId = null; try { final XmlRpcClient xmlrpc = new XmlRpcClient(blogUrl); final Vector<Object> params = new Vector<Object>(); params.add(APP_KEY); params.add(blogId); params.add(username); params.add(password); params.add(content); params.add(Boolean.TRUE); postId = xmlrpc.execute("blogger.newPost", params); } catch (Exception e) { LOG.error("", e); } return postId; } } /** * A <tt>BloggingApi</tt> implementation for the MetaWeblogAPI. * * @author Lasse Koskela */ public static class MetaWeblogApiClient implements BloggingApi { private static final long serialVersionUID = 5980798548858885672L; public Object newPost(final String blogUrl, final String blogId, final String username, final String password, final String category, final String subject, final String content) { Object postId = null; try { final XmlRpcClient xmlrpc = new XmlRpcClient(blogUrl); final Vector<Object> params = new Vector<Object>(); params.add(blogId); params.add(username); params.add(password); // MetaWeblogAPI expects the blog entry data elements in an // internal map-structure unlike Blogger API does. final Hashtable<String, Object> struct = new Hashtable<String, Object>(); struct.put("title", subject); struct.put("description", content); final Vector<Object> categories = new Vector<Object>(); if (category != null) { StringTokenizer tok = new StringTokenizer(category, ","); while (tok.hasMoreTokens()) { categories.add(tok.nextToken().trim()); } } struct.put("categories", categories); params.add(struct); params.add(Boolean.TRUE); postId = xmlrpc.execute("metaWeblog.newPost", params); } catch (Exception e) { LOG.error("", e); } return postId; } } /** * A <tt>BloggingApi</tt> implementation for the LiveJournal API. * * @author Lasse Koskela */ public static class LiveJournalApiClient implements BloggingApi { private static final long serialVersionUID = -372653261263803994L; // TODO: make this smarter so that it won't strip away linefeeds from // within <pre>formatted blocks... private String stripLineFeeds(final String input) { final StringBuilder s = new StringBuilder(); final char[] chars = input.toCharArray(); for (char aChar : chars) { if (aChar != '\n' && aChar != '\r') { s.append(aChar); } } return s.toString(); } public Object newPost(final String blogUrl, final String blogId, final String username, final String password, final String category, final String subject, final String content) { Object postId = null; try { final XmlRpcClient xmlrpc = new XmlRpcClient(blogUrl); final Vector<Object> params = new Vector<Object>(); final Hashtable<String, Object> struct = new Hashtable<String, Object>(); struct.put("username", username); // TODO: use challenge-based security struct.put("auth_method", "clear"); struct.put("password", password); struct.put("subject", subject); struct.put("event", stripLineFeeds(content)); struct.put("lineendings", "\n"); struct.put("security", "public"); final Calendar now = Calendar.getInstance(); struct.put("year", "" + now.get(Calendar.YEAR)); struct.put("mon", "" + (now.get(Calendar.MONTH) + 1)); struct.put("day", "" + now.get(Calendar.DAY_OF_MONTH)); struct.put("hour", "" + now.get(Calendar.HOUR_OF_DAY)); struct.put("min", "" + now.get(Calendar.MINUTE)); params.add(struct); postId = xmlrpc.execute("LJ.XMLRPC.postevent", params); } catch (Exception e) { LOG.error("", e); } return postId; } } /** * Selects a <tt>BloggingApi</tt> implementation based on a user-friendly * name. * * @param apiName * The name of the blogging API to use. One of <tt>blogger</tt>, * <tt>metaweblog</tt> or <tt>livejournal</tt>. * @return The <tt>BloggingApi</tt> implementation or <tt>null</tt> if * no matching implementation was found. * @throws CruiseControlException if something breaks */ public BloggingApi getBloggingApiImplementation(final String apiName) throws CruiseControlException { final Class implClass = API_CLIENTS.get(apiName); if (implClass != null) { LOG.debug("Mapped " + apiName + " to " + implClass.getName()); try { return (BloggingApi) implClass.newInstance(); } catch (Exception e) { throw new CruiseControlException( "Failed to instantiate Blogging API implementation, " + implClass.getName() + ", due to a " + e.getClass().getName() + ": " + e.getMessage()); } } return null; } /** * Posts the build results to the blog. * * @param subject * The subject for the blog entry. * @param content * The content for the blog entry. */ public void postBlogEntry(final String subject, final String content) { LOG.debug("Posting a blog entry to " + blogUrl); LOG.debug(" blogId=" + blogId); LOG.debug(" username=" + username); LOG.debug(" subject=" + subject); LOG.debug(" content=" + content); try { final BloggingApi apiClient = getBloggingApiImplementation(api); if (apiClient != null) { final Object postId = apiClient.newPost(blogUrl, blogId, username, password, category, subject, content); if (postId != null) { LOG.info("Blog entry " + postId + " created at " + blogUrl); } else { LOG.debug("Blog entry ID not available from " + blogUrl); } } else { LOG.error("No API associated with '" + api + "'"); } } catch (Exception e) { LOG.error("", e); } } /** * Called after the configuration is read to make sure that all the * mandatory parameters were specified.. * * @throws CruiseControlException * if there was a configuration error. */ public void validate() throws CruiseControlException { validateRequiredField("username", username); validateRequiredField("password", password); validateRequiredField("blogid", blogId); validateRequiredField("blogurl", blogUrl); validateURL("blogurl", blogUrl); validateOneOf("api", API_CLIENTS.keySet(), api); if (buildResultsURL != null) { validateURL("buildresultsurl", buildResultsURL); } if (logDir != null) { verifyDirectory("WeblogPublisher.logDir", logDir); } else { LOG.info("Using default log directory \"logs/<projectname>\""); } if (xslFile == null) { verifyDirectory("WeblogPublisher.xslDir", xslDir); verifyFile("WeblogPublisher.css", css); final String[] fileNames = getXslFileNames(); if (fileNames == null) { throw new CruiseControlException( "WeblogPublisher.getXslFileNames() can't return null"); } for (final String fileName : fileNames) { verifyFile("WeblogPublisher.xslDir/" + fileName, new File( xslDir, fileName)); } } else { verifyFile("WeblogPublisher.xslFile", xslFile); } } private void validateOneOf(final String fieldName, final Collection validValues, final String value) throws CruiseControlException { if (!validValues.contains(value)) { throw new CruiseControlException("Value for '" + fieldName + "' must be one of " + commaSeparated(validValues)); } } private String commaSeparated(final Collection values) { final StringBuilder s = new StringBuilder(); final Iterator i = values.iterator(); while (i.hasNext()) { s.append("'").append(i.next()).append("'"); if (i.hasNext()) { s.append(", "); } } return s.toString(); } private void validateURL(final String fieldName, final String url) throws CruiseControlException { try { new URL(url); } catch (MalformedURLException e) { throw new CruiseControlException(fieldName + " must be a valid URL: " + url); } } private void validateRequiredField(final String fieldName, final String value) throws CruiseControlException { if (value == null) { throw new CruiseControlException("Attribute " + fieldName + " is required."); } } private void verifyDirectory(final String dirName, final String dir) throws CruiseControlException { if (dir == null) { throw new CruiseControlException(dirName + " not specified in configuration file"); } final File dirFile = new File(dir); if (!dirFile.exists()) { throw new CruiseControlException(dirName + " does not exist : " + dirFile.getAbsolutePath()); } if (!dirFile.isDirectory()) { throw new CruiseControlException(dirName + " is not a directory : " + dirFile.getAbsolutePath()); } } private void verifyFile(final String fileName, final String file) throws CruiseControlException { if (file == null) { throw new CruiseControlException(fileName + " not specified in configuration file"); } verifyFile(fileName, new File(file)); } private void verifyFile(final String fileName, final File file) throws CruiseControlException { if (!file.exists()) { throw new CruiseControlException(fileName + " does not exist: " + file.getAbsolutePath()); } if (!file.isFile()) { throw new CruiseControlException(fileName + " is not a file: " + file.getAbsolutePath()); } } /** * Determines if the conditions are right for the blog entry to be posted. * * @param logHelper * <code>XMLLogHelper</code> wrapper for the build log. * @return whether or not the mail message should be sent. * @throws CruiseControlException if something breaks */ boolean shouldSend(final XMLLogHelper logHelper) throws CruiseControlException { if (logHelper.isBuildSuccessful()) { return shouldSendForSuccessfulBuild(logHelper); } else { return shouldSendForFailedBuild(logHelper); } } /** * Determines if the conditions are right for the blog entry to be posted. * * @param logHelper * <code>XMLLogHelper</code> wrapper for the build log. * @return whether or not the mail message should be sent. * @throws CruiseControlException if something breaks */ boolean shouldSendForFailedBuild(final XMLLogHelper logHelper) throws CruiseControlException { if (!logHelper.wasPreviousBuildSuccessful() && logHelper.isBuildNecessary() && !spamWhileBroken) { LOG.debug("spamWhileBroken is false, not sending email"); return false; } else { return true; } } /** * Determines if the conditions are right for the blog entry to be posted. * * @param logHelper * <code>XMLLogHelper</code> wrapper for the build log. * @return whether or not the mail message should be sent. * @throws CruiseControlException if something breaks */ boolean shouldSendForSuccessfulBuild(final XMLLogHelper logHelper) throws CruiseControlException { if (reportSuccess.equalsIgnoreCase(DEFAULT_REPORTSUCCESS)) { return true; } else if (reportSuccess.equalsIgnoreCase("never")) { return false; } else if (reportSuccess.equalsIgnoreCase("fixes")) { if (logHelper.wasPreviousBuildSuccessful()) { LOG.debug("reportSuccess is set to 'fixes', " + "not sending emails for repeated " + "successful builds."); return false; } } return true; } /** * Creates the subject for the blog entry. * * @param logHelper * <code>XMLLogHelper</code> wrapper for the build log. * @return <code>String</code> containing the subject line. * @throws CruiseControlException if something breaks */ String createSubject(final XMLLogHelper logHelper) throws CruiseControlException { final String projectName = logHelper.getProjectName(); final String label = logHelper.getLabel(); final boolean buildSuccessful = logHelper.isBuildSuccessful(); final boolean isFix = logHelper.isBuildFix(); return createSubject(projectName, label, buildSuccessful, isFix); } /** * Creates the subject for the blog entry. * * @param projectName project name * @param label label * @param buildSuccessful true if build successful * @param isFix true if build fixed prior failure * @return <code>String</code> containing the subject line. * @throws CruiseControlException if something breaks */ String createSubject(final String projectName, final String label, final boolean buildSuccessful, final boolean isFix) throws CruiseControlException { final StringBuilder subject = new StringBuilder(); if (subjectPrefix != null && subjectPrefix.trim().length() > 0) { subject.append(subjectPrefix).append(" "); } subject.append(projectName); if (buildSuccessful) { if (label.length() > 0) { subject.append(" ").append(label); } subject.append(isFix ? " - Build Fixed" : " - Build Successful"); } else { subject.append(" - Build Failed"); } return subject.toString(); } /** * Create the text to be blogged. * * @param projectName project name * @param logFileName log file name * @return created message; empty string if logDir not set */ String createMessage(final String projectName, final String logFileName) { String message; File inFile = null; try { if (logDir == null) { logDir = getDefaultLogDir(projectName); } inFile = new File(logDir, logFileName); message = transform(inFile); } catch (Exception ex) { LOG.error("error transforming " + (inFile != null ? inFile.getAbsolutePath() : ""), ex); message = createLinkLine(logFileName); } return message; } String getDefaultLogDir(final String projectName) throws CruiseControlException { // TODO: extract this duplication with ProjectXMLHelper.getLog() // into a single method somewhere return "logs" + File.separator + projectName; } String transform(final File xml) throws TransformerException, IOException { final StringBuilder messageBuffer = new StringBuilder(); if (xslFile != null) { transformWithSingleStylesheet(xml, messageBuffer); } else { messageBuffer.append(createLinkLine(xml.getName())); transformWithMultipleStylesheets(xml, messageBuffer); } return messageBuffer.toString(); } void transformWithMultipleStylesheets(final File inFile, final StringBuilder messageBuffer) throws IOException, TransformerException { final TransformerFactory tFactory = TransformerFactory.newInstance(); final File xslDirectory = new File(xslDir); final String[] fileNames = getXslFileNames(); for (final String fileName : fileNames) { final File xsl = new File(xslDirectory, fileName); messageBuffer.append("<p>\n"); appendTransform(inFile, xsl, messageBuffer, tFactory); } } void transformWithSingleStylesheet(final File inFile, final StringBuilder messageBuffer) throws IOException, TransformerException { final TransformerFactory tFactory = TransformerFactory.newInstance(); appendTransform(inFile, new File(xslFile), messageBuffer, tFactory); } void appendTransform(final File xml, final File xsl, final StringBuilder messageBuffer, TransformerFactory tFactory) throws TransformerException { LOG.debug("Transforming file " + xml.getName() + " with " + xsl.getName() + " ..."); final Transformer tformer = tFactory.newTransformer(new StreamSource(xsl)); final StringWriter sw = new StringWriter(); try { tformer.transform(new StreamSource(xml), new StreamResult(sw)); LOG.debug("Transformed file " + xml.getName() + " with " + xsl.getName() + " ..."); } catch (Exception e) { LOG.error("error transforming with xslFile " + xsl.getName(), e); return; } messageBuffer.append(sw.toString()); } String createLinkLine(final String logFileName) { if (buildResultsURL == null) { return ""; } final String url = createBuildResultsUrl(logFileName); final StringBuilder linkLine = new StringBuilder(); linkLine.append("<p>View results here -> <a href=\""); linkLine.append(url); linkLine.append("\">"); linkLine.append(url); linkLine.append("</a></p>"); return linkLine.toString(); } String createBuildResultsUrl(final String logFileName) { final int startName = logFileName.lastIndexOf(File.separator) + 1; final int endName = logFileName.lastIndexOf("."); final String baseLogFileName = logFileName.substring(startName, endName); final StringBuilder url = new StringBuilder(buildResultsURL); if (buildResultsURL.indexOf("?") == -1) { url.append("?"); } else { url.append("&"); } url.append("log="); url.append(baseLogFileName); return url.toString(); } }