com.streamreduce.util.JiraClient.java Source code

Java tutorial

Introduction

Here is the source code for com.streamreduce.util.JiraClient.java

Source

/*
 * Copyright 2012 Nodeable Inc
 *
 *    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
 *
 *        http://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.
 */

package com.streamreduce.util;

import com.streamreduce.ProviderIdConstants;
import com.streamreduce.core.model.Connection;
import com.streamreduce.core.model.InventoryItem;
import com.streamreduce.core.model.ProjectHostingIssue;
import com.streamreduce.core.service.exception.InvalidCredentialsException;
import net.sf.json.JSONArray;
import net.sf.json.JSONException;
import net.sf.json.JSONObject;
import org.apache.abdera.model.Entry;
import org.apache.http.Header;
import org.apache.http.message.BasicHeader;
import org.springframework.util.Assert;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;

import javax.ws.rs.HttpMethod;
import javax.ws.rs.core.MediaType;
import javax.xml.namespace.QName;
import javax.xml.soap.MessageFactory;
import javax.xml.soap.SOAPException;
import javax.xml.soap.SOAPFault;
import javax.xml.soap.SOAPMessage;
import javax.xml.soap.SOAPPart;
import javax.xml.transform.Source;
import javax.xml.transform.stream.StreamSource;
import java.io.IOException;
import java.io.StringReader;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * JiraClient provides necessary methods for interacting with Jira.
 */
public class JiraClient extends AbstractProjectHostingClient {

    private final String encodingStyle = "http://schemas.xmlsoap.org/soap/encoding/";
    private final String jiraBeanSchema = "http://beans.soap.rpc.jira.atlassian.com";

    private String jiraRestAPIBase = null;
    private String jiraToken = null;
    private String confluenceToken = null;

    private enum JiraStudioApp {
        CONFLUENCE, JIRA
    }

    /**
     * Constructs a client for GitHub using the credentials in the connection provided.
     *
     * @param connection the connection to use for interacting with GitHub
     */
    public JiraClient(Connection connection) {
        super(connection);

        Assert.isTrue(connection.getProviderId().equals(ProviderIdConstants.JIRA_PROVIDER_ID));

        init();
    }

    /**
     * Initializes the client.
     */
    private void init() {
        debugLog(LOGGER, "Client created for " + getConnectionCredentials().getIdentity());

        jiraRestAPIBase = getBaseUrl() + "/rest/api/latest/";

        try {
            jiraToken = login(JiraStudioApp.JIRA);
            confluenceToken = login(JiraStudioApp.CONFLUENCE);
        } catch (SOAPException e) {
            LOGGER.error("Unable to login to Jira via SOAP for connection (" + getConnectionId() + ")", e);
        }
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void cleanUp() {
        try {
            logout(JiraStudioApp.JIRA, jiraToken);
            logout(JiraStudioApp.CONFLUENCE, confluenceToken);
        } catch (SOAPException e) {
            LOGGER.error("Unable to logout of Jira via SOAP for connection " + getConnectionId(), e);
        }

        super.cleanUp();
    }

    /**
     * Returns a JSON object representing a Jira project's details.
     *
     * @param projectKey the project's key
     * @return the JSON representation of a project
     * @throws InvalidCredentialsException if the connection's credentials are invalid
     * @throws IOException                 if anything else goes wrong
     */
    public JSONObject getProjectDetails(String projectKey) throws InvalidCredentialsException, IOException {
        debugLog(LOGGER, "Getting project details for " + projectKey);

        String projectDetailsUrl = jiraRestAPIBase + "project/" + projectKey;
        List<JSONObject> rawResponse = makeRESTRequest(projectDetailsUrl, Integer.MAX_VALUE, true, false);

        // Should never happen where we're returning null but just in case
        return (rawResponse.size() == 1 ? rawResponse.get(0) : null);
    }

    /**
     * Returns a list of JSONObjects representing the Jira projects the connecting user has access to.
     *
     * @param anonymous whether the request should be done anonymously
     * @return the JSON representation of the available Jira projects
     * @throws InvalidCredentialsException if the connection's credentials are invalid
     * @throws IOException                 if anything else goes wrong
     */
    public List<JSONObject> getProjects(boolean anonymous) throws InvalidCredentialsException, IOException {
        debugLog(LOGGER, "Getting projects" + (anonymous ? " anonymously" : ""));

        String projectsUrl = jiraRestAPIBase + "project";
        List<JSONObject> projects;

        if (anonymous) {
            projects = makeRESTRequest(projectsUrl, Integer.MAX_VALUE, true, true);
        } else {
            projects = makeRESTRequest(projectsUrl, Integer.MAX_VALUE, true, false);
        }

        debugLog(LOGGER, "  Projects found: " + (projects != null ? projects.size() : 0));

        return projects;
    }

    /**
     * {@inheritDoc}
     */
    @Override
    public void validateConnection() throws InvalidCredentialsException, IOException {
        debugLog(LOGGER, "Validating connection");

        String validationUrl = getBaseUrl() + "/rest/auth/1/session";

        makeRESTRequest(validationUrl, Integer.MAX_VALUE, true, false);
    }

    /**
     * Checks if a Jira project key is "public" by seeing if the project can be found by the anonymous user.
     *
     * @param projectKey the project key
     * @return whether or not the project key is a public (anonymously accessible) project
     * @throws InvalidCredentialsException Should never happen since credentials aren't used
     * @throws IOException                 Should never happen since the connection has been validated prior to use
     */
    public boolean isProjectPublic(String projectKey) throws InvalidCredentialsException, IOException {
        debugLog(LOGGER, "Checking if " + projectKey + " is public");

        List<JSONObject> publicProjectsJSON = getProjects(true);

        for (JSONObject project : publicProjectsJSON) {
            if (project.getString("key").equals(projectKey)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Retrieves all activity for the projects passed in with no limit on the maximum number of activity
     * entries returned.
     *
     * @see #getActivity(java.util.Set, int)
     */
    public List<Entry> getActivity(Set<String> monitoredProjectKeys) {
        return getActivity(monitoredProjectKeys, Integer.MAX_VALUE);
    }

    /**
     * Retrieves the activity for the given connection based on the last poll date stored in the connection.
     * <p/>
     * Note: This list is already sorted in the proper order, contains no duplicates and contains only entries that
     * are pertinent:
     * <p/>
     * * Entries will correspond with a project in the projectKeys set unless that set is empty/null and then
     * entries can be for any project
     * * Entries will after the last activity date in the connection
     *
     * @param projectKeys   the project keys we're interested in or null for all
     * @param maxActivities the maximum number of results to return
     * @return list of Entry representing activity entries
     */
    public List<Entry> getActivity(Set<String> projectKeys, int maxActivities) {
        // The Jira Activity Stream is a combination of the connection's URL, the time in millis since the last
        // poll and a list of projects being monitored.  To make sure we only pull activity we need, we are
        // using a timestamp to know when we last polled and a list of monitored project keys, unless there are
        // no unmonitored projects in which case we will not provide any project keys, so we only pull in new
        // activity.  Here is an example URL to pull all SOBA activity since 2011-11-01 00:00:00.000:
        //
        //   [-----CONNECTION URL-----][-----COMMON PARTS-----][-PROJECT FILTER-][-------------DATE FILTER-------------]
        //   https://nodeable.jira.com/activity?maxActivities=100&stream=key+IS+SOBA&stream=update-date+AFTER+1320127200000
        //
        // To efficiently retrieve activity from Jira, we first make a request to get the activity after the
        // last poll date.  If the number of results is the maximum number of results, we might have a
        // situation where we need to handle pagination because there is more activity than we can retrieve
        // at one time.  The way we handle this is we figure out the oldest activity in the results and do a
        // between request so that we get the activity between the oldest known activity and the previous
        // poll until we get a response of zero, indicating no more activity.

        debugLog(LOGGER, "Getting activity");

        final int resultsPerPage = 100;
        final StringBuilder requestURLBuilder = new StringBuilder().append(getBaseUrl())
                .append("/activity?maxResults=").append(resultsPerPage);

        // Only pull in activity for projects we are monitoring
        requestURLBuilder.append("&streams=key+IS");

        for (String projectKey : projectKeys) {
            requestURLBuilder.append("+").append(projectKey);
        }

        // Create a base URL just in case we have to do subsequent calls for pagination
        final String baseRequestURL = requestURLBuilder.toString();
        Date afterDate = getLastActivityPollDate();
        String requestUrl = baseRequestURL + "&streams=update-date+AFTER+" + afterDate.getTime();

        List<Entry> allEntries = new ArrayList<>();
        List<Entry> feedEntries = FeedUtils.getFeedEntries(requestUrl, getConnectionCredentials().getIdentity(),
                getConnectionCredentials().getCredential());

        // Since Jira doesn't have any pagination, we have to walk from newest activity to
        // oldest so we will retrieve all feed items first and then handle them so the
        // messages are created in the same order that the activity was created.
        while (feedEntries != null && feedEntries.size() > 0 && allEntries.size() < maxActivities) {
            for (Entry entry : feedEntries) {
                if (allEntries.size() < maxActivities) {
                    allEntries.add(entry);
                } else {
                    break;
                }
            }

            if (resultsPerPage == feedEntries.size()) {
                Date oldestActivityDate = feedEntries.get(feedEntries.size() - 1).getPublished();
                requestUrl = baseRequestURL + "&streams=update-date+BETWEEN+" + afterDate.getTime() + "+"
                        + oldestActivityDate.getTime();

                feedEntries = FeedUtils.getFeedEntries(requestUrl, getConnectionCredentials().getIdentity(),
                        getConnectionCredentials().getCredential());
            } else {
                feedEntries = null;
            }
        }

        // Reverse them since the order is newest to oldest and we want to create messages in the order
        // in which they really happened
        Collections.reverse(allEntries);

        debugLog(LOGGER, "  Activities found: " + allEntries.size());

        return allEntries;
    }

    /**
     * Returns a map with the following keys in it or null if the entry is unhandleable:
     * <p/>
     * * title: This is the title of the activity
     * * content: This is the content of the activity (Summarizing changes when necessary)
     * * hashtags: A set of hashtags
     *
     * @param inventoryItem the inventory item the activity entry corresponds to
     * @param entry         the JSONObject to parse
     * @return the map described above
     */
    public Map<String, Object> getPartsForActivity(InventoryItem inventoryItem, Entry entry) {
        Assert.isTrue(getConnectionId().equals(inventoryItem.getConnection().getId()));

        String projectKey = inventoryItem.getExternalId();
        Map<String, Object> activityParts = new HashMap<>();
        String title = entry.getTitle() != null ? MessageUtils.cleanEntry(entry.getTitle()) : null;
        String rawContent = concatRawTitleAndContent(entry);
        String content = entry.getContent() != null ? MessageUtils.cleanEntry(entry.getContent()) : null;
        Set<String> hashtags = new HashSet<>();
        String applicationHashtag;
        String activityHashtag;
        String activityTargetHashtag = null;

        // Bring in the inventory item hashtags
        for (String hashtag : inventoryItem.getHashtags()) {
            hashtags.add(hashtag);
        }

        // If we cannot parse the title, we cannot create the message so do nothing and let the caller log
        if (title == null) {
            LOGGER.warn("There is no associated <title /> element for the Jira activity.");
            return null;
        }

        // Always add the project
        hashtags.add("#" + projectKey.toLowerCase());

        // Jira activity information gathering is a pain in the ass.  It's a pretty complex process that involves a lot
        // of raw XML parsing.  The activity title and content are pretty simple since they are given to us, other than
        // having to remove HTML tags from them due to the title/content being HTML strings, but the hashtags are where
        // things need a little extra explanation.
        //
        // Hashtags for Jira activity will always have three hashtags:
        //
        //  * Provider: This is the project hosting provider (#jira)
        //  * Application: A hashtag to indicate the Jira application the activity corresponds with like #issue
        //                 for all Jira activity, #source for all Fisheye/Crucible activity and #wiki for all
        //                 Confluence activity.
        //  * Activity: A hashtag to indicate the type of activity like #changeset
        //              for commits, #review for code reviews and others.
        //  * Generated: This is a hashtag or set of hashtags that gets generated based on the type of activity
        //               and/or the target of the activity.
        //
        // If the activity is a comment, there will be an additional #comment hashtag above and beyond the usually
        // generated hashtags.  Right now there are only two types of activity that will generate tags: issue activity
        // and wiki activity.  Each implementation documents its process in detail to explain how/why the code is
        // as-is.

        // Let's gather the application id
        org.apache.abdera.model.Element applicationElement = entry.getFirstChild(
                new QName("http://streams.atlassian.com/syndication/general/1.0", "application", "atlassian"));
        String application = applicationElement != null ? applicationElement.getText() : null;

        // Just in case, fail if there is no atlassian:application.
        if (application == null) {
            LOGGER.error("There is no associated <atlassian:application /> element for the Jira activity.");
            return null;
        }

        // Add the corresponding hashtag for the given application string
        if (application.equals("com.atlassian.fisheye")) {
            applicationHashtag = "#source";
        } else if (application.equals("com.atlassian.confluence")) {
            applicationHashtag = "#wiki";
        } else if (application.equals("com.atlassian.jira")) {
            applicationHashtag = "#issue";
        } else {
            LOGGER.error("Unable to handle Jira application type: " + application);
            return null;
        }

        // Let's gather the activity information
        org.apache.abdera.model.Element activityObjectElement = entry
                .getFirstChild(new QName("http://activitystrea.ms/spec/1.0/", "object", "activity"));

        // Just in case, fail if there is no activity:object.
        if (activityObjectElement == null) {
            LOGGER.error("There is no associated <activity:object /> element for the Jira activity.");
            return null;
        }

        org.apache.abdera.model.Element activityObjectTypeElement = activityObjectElement
                .getFirstChild(new QName("http://activitystrea.ms/spec/1.0/", "object-type", "activity"));

        // Just in case, fail if there is no activity:object > activity:object-type
        if (activityObjectTypeElement == null) {
            LOGGER.error("There is no associated <activity:object /> > <activity:object-type> element for "
                    + "the Jira activity.");
            return null;
        }

        String rawActivityType = activityObjectTypeElement.getText();

        activityHashtag = sanitizeJiraHashtag(
                "#" + rawActivityType.substring(rawActivityType.lastIndexOf("/") + 1));

        // Let's gather the activity target information, if any
        org.apache.abdera.model.Element activityTargetElement = entry
                .getFirstChild(new QName("http://activitystrea.ms/spec/1.0/", "target", "activity"));

        if (activityTargetElement != null) {
            org.apache.abdera.model.Element activityTargetType = activityTargetElement
                    .getFirstChild(new QName("http://activitystrea.ms/spec/1.0/", "object-type", "activity")); // Guaranteed to be there
            String rawTargetType = activityTargetType.getText();

            activityTargetHashtag = sanitizeJiraHashtag(
                    "#" + rawTargetType.substring(rawTargetType.lastIndexOf("/") + 1));
        }

        hashtags.add(applicationHashtag);
        hashtags.add(activityHashtag);

        if (activityTargetHashtag != null) {
            hashtags.add(activityTargetHashtag);
        }

        // Let's generate the autotags where applicable
        if (applicationHashtag.equals("#wiki")) {
            // For Confluence, attempt to get the page's labels and add them as hashtags
            handleJiraWikiAutotags(projectKey, activityObjectElement, entry, hashtags);
        } else if (applicationHashtag.equals("#issue") && !activityHashtag.equals("#review")
                && (activityTargetHashtag == null || !activityTargetHashtag.equals("#review"))) {
            // For Jira, attempt to get the issue's type, priority and status and add them as hashtags
            //
            // Note: We do not generate autotags for Crucible (review) activity
            handleJiraIssueAutotags(projectKey, activityObjectElement, hashtags);
        }

        activityParts.put("title", title);
        activityParts.put("content", content);
        activityParts.put("rawContent", rawContent);
        activityParts.put("hashtags", hashtags);

        return activityParts;
    }

    public SOAPMessage invokeSoap(JiraStudioApp app, String soapBody) throws SOAPException {
        String cacheKey = (app + "-SOAP-" + soapBody.hashCode());
        Object objectFromCache = requestCache.getIfPresent(cacheKey);

        if (objectFromCache != null) {
            debugLog(LOGGER, "  (From cache)");
            return (SOAPMessage) objectFromCache;
        }

        // Wrap the SOAP body content in an envelope/body container
        StringBuilder sb = new StringBuilder();
        String soapBaseURL = getBaseUrl();
        String soapNamespaceURL;

        sb.append("<soapenv:Envelope ").append("xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" ")
                .append("xmlns:xsd=\"http://www.w3.org/2001/XMLSchema\" ")
                .append("xmlns:soapenv=\"http://schemas.xmlsoap.org/soap/envelope/\" ");

        switch (app) {
        case CONFLUENCE:
            soapNamespaceURL = "http://soap.rpc.confluence.atlassian.com";
            soapBaseURL += "/wiki/rpc/soap-axis/confluenceservice-v1";
            break;
        case JIRA:
            soapNamespaceURL = "http://soap.rpc.jira.atlassian.com";
            soapBaseURL += "/rpc/soap/jirasoapservice-v2";
            break;
        default:
            throw new SOAPException("Unknown Jira Studio application: " + app);
        }

        sb.append("xmlns:soap=\"" + soapNamespaceURL + "\">\n");
        sb.append("<soapenv:Body>\n");
        sb.append(soapBody);
        sb.append("</soapenv:Body></soapenv:Envelope>");

        String rawResponse;
        List<Header> requestHeaders = new ArrayList<>();

        requestHeaders.add(new BasicHeader("SOAPAction", ""));

        try {
            rawResponse = HTTPUtils.openUrl(soapBaseURL, "POST", sb.toString(), MediaType.TEXT_XML, null, null,
                    requestHeaders, null);
        } catch (Exception e) {
            LOGGER.error(String.format("Unable to make SOAP call to %s: %s", soapBaseURL, e.getMessage()), e);
            throw new SOAPException(e);
        }

        Source response = new StreamSource(new StringReader(rawResponse));
        MessageFactory msgFactory = MessageFactory.newInstance();
        SOAPMessage message = msgFactory.createMessage();
        SOAPPart env = message.getSOAPPart();
        env.setContent(response);

        if (message.getSOAPBody().hasFault()) {
            SOAPFault fault = message.getSOAPBody().getFault();
            LOGGER.error("soap fault in jira soap response: " + fault.getFaultString());
        }

        requestCache.put(cacheKey, message);

        return message;
    }

    public List<Element> asList(NodeList nodeList) {
        ArrayList<Element> elements = new ArrayList<>();
        for (int i = 0; i < nodeList.getLength(); i++) {
            elements.add((Element) nodeList.item(i));
        }
        return elements;
    }

    private String login(JiraStudioApp app) throws SOAPException {
        String username = getConnectionCredentials().getIdentity();

        debugLog(LOGGER, "Logging into " + (app == JiraStudioApp.CONFLUENCE ? "Confluence" : "Jira")
                + " via SOAP as " + username);

        StringBuilder sb = new StringBuilder()
                .append("  <soap:login soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("    <in0 xsi:type=\"xsd:string\">" + username + "</in0>\n")
                .append("    <in1 xsi:type=\"xsd:string\">" + getConnectionCredentials().getCredential()
                        + "</in1>\n")
                .append("  </soap:login>\n");
        SOAPMessage loginResponse = invokeSoap(app, sb.toString());
        return loginResponse.getSOAPBody().getElementsByTagName("loginReturn").item(0).getFirstChild()
                .getNodeValue();
    }

    private boolean logout(JiraStudioApp app, String token) throws SOAPException {
        debugLog(LOGGER, "Logging out of " + (app == JiraStudioApp.CONFLUENCE ? "Confluence" : "Jira")
                + " via SOAP for " + getConnectionCredentials().getIdentity());

        StringBuilder sb = new StringBuilder()
                .append("  <soap:logout soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("    <in0 xsi:type=\"xsd:string\">" + token + "</in0>\n").append("  </soap:logout>\n");
        SOAPMessage logoutResponse = invokeSoap(app, sb.toString());
        String logoutReturn = logoutResponse.getSOAPBody().getElementsByTagName("logoutReturn").item(0)
                .getFirstChild().getNodeValue();

        return Boolean.valueOf(logoutReturn);
    }

    // Jira SOAP calls

    public List<org.w3c.dom.Element> getSubTaskIssueTypes() throws SOAPException {
        debugLog(LOGGER, "Getting sub-task issue types");

        StringBuilder sb = new StringBuilder()
                .append("  <soap:getSubTaskIssueTypes soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("     <in0 xsi:type=\"xsd:string\">" + jiraToken + "</in0>\n")
                .append("  </soap:getSubTaskIssueTypes>\n");
        SOAPMessage subTaskIssueTypesResponse = invokeSoap(JiraStudioApp.JIRA, sb.toString());
        NodeList subTaskIssueTypes = subTaskIssueTypesResponse.getSOAPBody().getElementsByTagName("multiRef");
        return asList(subTaskIssueTypes);
    }

    public List<Element> getIssueTypes() throws SOAPException {
        debugLog(LOGGER, "Getting issue types");

        StringBuilder sb = new StringBuilder()
                .append("  <soap:getIssueTypes soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("     <in0 xsi:type=\"xsd:string\">" + jiraToken + "</in0>\n")
                .append("  </soap:getIssueTypes>\n");
        SOAPMessage issueTypesResponse = invokeSoap(JiraStudioApp.JIRA, sb.toString());
        NodeList issueTypes = issueTypesResponse.getSOAPBody().getElementsByTagName("multiRef");
        return asList(issueTypes);
    }

    public List<Element> getIssuePriorities() throws SOAPException {
        debugLog(LOGGER, "Getting issue priorities");

        StringBuilder sb = new StringBuilder()
                .append("  <soap:getPriorities soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("     <in0 xsi:type=\"xsd:string\">" + jiraToken + "</in0>\n")
                .append("  </soap:getPriorities>\n");
        SOAPMessage issuePrioritiesResponse = invokeSoap(JiraStudioApp.JIRA, sb.toString());
        NodeList issuePriorities = issuePrioritiesResponse.getSOAPBody().getElementsByTagName("multiRef");
        return asList(issuePriorities);
    }

    public List<Element> getIssueStatuses() throws SOAPException {
        debugLog(LOGGER, "Getting issue statuses");

        StringBuilder sb = new StringBuilder()
                .append("  <soap:getStatuses soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("     <in0 xsi:type=\"xsd:string\">" + jiraToken + "</in0>\n")
                .append("  </soap:getStatuses>\n");
        SOAPMessage issueStatusesResponse = invokeSoap(JiraStudioApp.JIRA, sb.toString());
        NodeList issueStatuses = issueStatusesResponse.getSOAPBody().getElementsByTagName("multiRef");
        return asList(issueStatuses);
    }

    public String createIssue(ProjectHostingIssue issue) throws SOAPException {
        debugLog(LOGGER, "Creating issue");

        StringBuilder sb = new StringBuilder()
                .append("  <soap:createIssue soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("    <in0 xsi:type=\"xsd:string\">" + jiraToken + "</in0>\n")
                .append("    <in1 xsi:type=\"bean:RemoteIssue\" xmlns:bean=\"" + jiraBeanSchema + "\">\n")
                .append("      <description xsi:type=\"xsd:string\">" + issue.getDescription() + "</description>\n")
                .append("      <project xsi:type=\"xsd:string\">" + issue.getProject() + "</project>\n")
                .append("      <summary xsi:type=\"xsd:string\">" + issue.getSummary() + "</summary>\n")
                .append("      <type xsi:type=\"xsd:string\">" + issue.getType() + "</type>\n")
                .append("    </in1>\n").append("  </soap:createIssue>\n");
        SOAPMessage createIssueResponse = invokeSoap(JiraStudioApp.JIRA, sb.toString());

        return createIssueResponse.getSOAPBody().getElementsByTagName("id").item(0).getFirstChild().getNodeValue();
    }

    public List<Element> getIssueDetails(String issue) throws SOAPException {
        debugLog(LOGGER, "Getting issue details for " + issue);

        StringBuilder sb = new StringBuilder()
                .append("  <soap:getIssue soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("     <in0 xsi:type=\"xsd:string\">" + jiraToken + "</in0>\n")
                .append("     <in1 xsi:type=\"xsd:string\">" + issue + "</in1>\n").append("  </soap:getIssue>\n");
        SOAPMessage issueDetailsResponse = invokeSoap(JiraStudioApp.JIRA, sb.toString());
        NodeList issueDetails = issueDetailsResponse.getSOAPBody().getElementsByTagName("multiRef");
        return asList(issueDetails);
    }

    // Confluence SOAP calls

    public List<Element> getWikiPageLabels(long pageId) throws SOAPException {
        debugLog(LOGGER, "Getting labels for wiki page " + pageId);
        StringBuilder sb = new StringBuilder()
                .append("  <soap:getLabelsById soapenv:encodingStyle=\"" + encodingStyle + "\">\n")
                .append("    <in0 xsi:type=\"xsd:string\">" + confluenceToken + "</in0>\n")
                .append("    <in1 xsi:type=\"xsd:long\">" + pageId + "</in1>\n")
                .append("  </soap:getLabelsById>\n");
        SOAPMessage labelsResponse = invokeSoap(JiraStudioApp.CONFLUENCE, sb.toString());
        NodeList labels = labelsResponse.getSOAPBody().getElementsByTagName("getLabelsByIdReturn");
        return asList(labels);
    }

    /**
     * Returns the project key based on the activity:object element.
     *
     * @param activityObjectElement the activity:object element
     * @param monitoredProjectKeys  the set of project keys monitored in Nodeable
     * @return the project key or null if one couldn't be found
     */
    public String getProjectKeyOfEntry(org.apache.abdera.model.Element activityObjectElement,
            Set<String> monitoredProjectKeys) {

        // not sure how/why this would happen, but logs indicate it does
        if (activityObjectElement == null) {
            LOGGER.error("activityObjectElement is null");
            return null;
        }

        QName linkQname = new QName("http://www.w3.org/2005/Atom", "link");
        org.apache.abdera.model.Element linkElement = activityObjectElement.getFirstChild(linkQname);

        if (linkElement == null) {
            LOGGER.error("Unable to parse the linkElement of the Jira activity:object "
                    + JSONUtils.xmlToJSON(activityObjectElement.toString()));
            return null;
        }

        URL href = getURLFromLinkElement(linkElement);

        // the returned href may be null
        if (href == null) {
            LOGGER.error("Unable to parse the link of the Jira activity:object for: "
                    + JSONUtils.xmlToJSON(activityObjectElement.toString()));
            return null;
        }

        String hrefAsString = href.toString();
        String[] pathParts = href.getPath().split("/");
        String projectKey = getProjectKeyFromId(pathParts[pathParts.length - 1]);

        if (!monitoredProjectKeys.contains(projectKey)) {
            // Since the activity:object > link didn't give us the project key based on the logic
            // above (last path segment is the project key), lets try to see if we can adjust our
            // logic for the special cases as the approach above is the norm.

            if (hrefAsString.contains("/wiki/display/")) {
                // This is specialized logic to handle wiki comment activity
                projectKey = hrefAsString.split("/wiki/display/")[1].split("/")[0];
            } else if (hrefAsString.contains("/wiki/download/")) {
                // This is specialized logic to handle wiki attachment activity
                linkElement = ((Entry) activityObjectElement.getParentElement()).getFirstChild(linkQname);
                href = getURLFromLinkElement(linkElement);
                hrefAsString = href.toString();
                projectKey = hrefAsString.split("/wiki/display/")[1].split("/")[0];
            } else {
                // This is specialized logic to handle new/updated/deleted wiki
                org.apache.abdera.model.Element activityTargetElement = ((Entry) activityObjectElement
                        .getParentElement()).getFirstChild(
                                new QName("http://activitystrea.ms/spec/1.0/", "target", "activity"));

                if (activityTargetElement != null) {
                    linkElement = activityTargetElement.getFirstChild(linkQname);
                    href = getURLFromLinkElement(linkElement);
                    pathParts = href.getPath().split("/");
                    projectKey = pathParts[pathParts.length - 1];
                }
            }

            projectKey = getProjectKeyFromId(projectKey);

            if (!monitoredProjectKeys.contains(projectKey)) {
                // Revert back to null
                projectKey = null;
            }
        }

        return projectKey;
    }

    /**
     * Returns the URL object from the link element
     *
     * @param linkElement the link element
     * @return a URL for the href attribute of the link element or null if there was problem
     */
    private URL getURLFromLinkElement(org.apache.abdera.model.Element linkElement) {
        // Just in case, return null if there is no link to parse
        if (linkElement == null || !linkElement.getQName().getLocalPart().equals("link")) {
            return null;
        }

        String linkValue = linkElement.getAttributeValue("href");

        try {
            return URI.create(linkValue).toURL();
        } catch (MalformedURLException e) {
            // Should never happen
            LOGGER.error(String.format("Error creating a URL for %s: %s", linkValue, e.getMessage()), e);
            return null;
        }
    }

    /**
     * Returns the project key based on the passed in id.
     *
     * @param id the potential project id
     * @return the project key
     */
    private String getProjectKeyFromId(String id) {
        String projectKey = id;

        // Handle Jira issue ids
        if (id.contains("-")) {
            projectKey = projectKey.substring(0, projectKey.lastIndexOf("-"));
        }

        // Handle Crucible ids
        if (projectKey.contains("-")) {
            projectKey = projectKey.substring(projectKey.indexOf("-") + 1);
        }

        return projectKey;
    }

    /**
     * Takes a list of elements, each having an id and name child element, and finds the name for the corresponding
     * id passed in.
     *
     * @param elements the elements to search for the corresponding id
     * @param id       the id we're interested in finding the name for
     * @return the name or null if one could not be found
     */
    private String getNameForId(List<org.w3c.dom.Element> elements, String id) {
        String name = null;

        for (org.w3c.dom.Element element : elements) {
            NodeList idNodes = element.getElementsByTagName("id");

            if (idNodes == null || idNodes.getLength() == 0) {
                // Nothing we can do so just return null so the caller can log
                return null;
            }

            if (idNodes.item(0).getTextContent().equals(id)) {
                NodeList nameNodes = element.getElementsByTagName("name");

                if (nameNodes == null || nameNodes.getLength() == 0) {
                    // Nothing we can do so just return null so the caller can log
                    return null;
                }

                name = nameNodes.item(0).getTextContent();
                break;
            }
        }

        return name;
    }

    /**
     * The way we're getting hashtags from the Jira Activity Stream means we sometimes get values from Jira that we'd
     * rather replace with something more meaningful.  Below are the cases we'll be handling:
     * <p/>
     * * article   : This corresponds with a blog entry and so we'll return blog
     * * file      : This corresponds with an attachment and so we'll return attachment
     * * repository: This corresponds with a source activity and we're already using #source
     * * space     : This corresponds with a wiki activity and we're using #wiki
     * <p/>
     * This method also removes all illegal characters.
     *
     * @param hashtag the hashtag to sanitize
     * @return the sanitized hashtag or the original hashtag if we do not sanitize the hashtag passed in
     */
    private String sanitizeJiraHashtag(String hashtag) {
        if (hashtag.equals("#article")) {
            return "#blog";
        } else if (hashtag.equals("#file")) {
            return "#attachment";
        } else if (hashtag.equals("#repository")) {
            return "#source";
        } else if (hashtag.equals("#space")) {
            return "#wiki";
        }

        return removeIllegalHashtagCharacters(hashtag);
    }

    /**
     * Removes illegal characters from hashtags.
     *
     * @param hashtag the hashtag to cleanup
     * @return the cleaned up hashtag
     */
    private String removeIllegalHashtagCharacters(String hashtag) {
        String regex = "[^a-zA-Z0-9._\\-]";
        int start = 0;

        // Figure out the first character that is not a #
        for (int i = 0; i < hashtag.length(); i++) {
            if (hashtag.charAt(i) != '#') {
                start = i;
                break;
            }
        }

        return hashtag.substring(0, start) + hashtag.substring(start).replaceAll(regex, "");
    }

    /**
     * Handles the auto-tags, extra metadata, to be added to the activity message for Jira issue activity.
     *
     * @param projectKey     the project key of the inventory item
     * @param activityObject the raw <activity:object /> element of the activity
     * @param entry          the root element for the activity entry
     * @param hashtags       the hashtags set we will manipulate
     */
    private void handleJiraWikiAutotags(String projectKey, org.apache.abdera.model.Element activityObject,
            Entry entry, Set<String> hashtags) {
        // Right now, the only additional auto-tags we add for wiki activity are the page's labels.  To do this, we
        // parse the page id from ref attribute of the <thr:in-reply-to /> element.  Once we get this, we make a SOAP
        // call to get the page labels for that id.
        //
        // Note: This does not appear to work for blog/article entries.  The pageId we parse from the activity entry
        //       always returns an empty array response when gathering the labels.  We will still attempt to retrieve
        //       the labels for blog/article entries just in case it gets fixed.
        org.apache.abdera.model.Element thr = entry
                .getFirstChild(new QName("http://purl.org/syndication/thread/1.0", "in-reply-to", "thr"));

        if (thr != null) {
            String pageUrl = thr.getAttributeValue("ref");
            long pageId;

            // Just in case
            if (pageUrl == null) {
                LOGGER.error("Unable to parse the thr:in-reply-to of the Jira activity:object for: "
                        + JSONUtils.xmlToJSON(activityObject.toString()));
                return;
            }

            try {
                pageId = Long.valueOf(pageUrl.substring(pageUrl.lastIndexOf("/") + 1));
            } catch (NumberFormatException e) {
                // Not much we can do so log the error and continue processing
                LOGGER.error("Unexpected wiki page id when parsing activity URL (" + pageUrl + "): ",
                        e.getMessage());
                return;
            }

            try {
                List<org.w3c.dom.Element> labels = getWikiPageLabels(pageId);

                for (org.w3c.dom.Element label : labels) {
                    // Confluence labels are returned as an array and we're only interested in ones that have children.
                    //
                    // Note: When a page has no labels, we get back an empty array element and so we need to check that
                    //       the label has children before trying to use it.
                    if (!label.hasChildNodes()) {
                        continue;
                    }

                    NodeList labelNames = label.getElementsByTagName("name");

                    if (labelNames == null) {
                        // Not much we can do at this point but log the problem
                        LOGGER.error("Unexpected response when retrieving labels for wiki page with an " + "id of "
                                + pageId + " in the " + projectKey + " project.");
                    } else {
                        hashtags.add(removeIllegalHashtagCharacters("#" + labelNames.item(0).getTextContent()));
                    }
                }
            } catch (SOAPException e) {
                // Not much we can do at this point but log the problem
                LOGGER.error(
                        "Unable to make SOAP call to get wiki labels for " + projectKey + ": " + e.getMessage());
            }
        }
    }

    /**
     * Handles the auto-tags, extra metadata, to be added to the activity message for Jira issue activity.
     *
     * @param projectKey     the project key of the invenentory item
     * @param activityObject the raw <activity:object /> element of the activity
     * @param hashtags       the hashtags set we will manipulate
     */
    private void handleJiraIssueAutotags(String projectKey, org.apache.abdera.model.Element activityObject,
            Set<String> hashtags) {

        // Right now, the only additional auto-tags we add for issue activity are the issue's type, status and
        // priority.  To do this, we parse the first href attribute of the first <link /> element of the
        // <activity:object /> tag.  Once we get the issue name, we get the issues details with a SOAP call.  Once we
        // get the issue's details, we get the integer id for the issue type, status and priority.  We then have to
        // make three subsequent calls to get the list of issue types, issue statuses and issue priorities.  Once those
        // are available, we reference the integer (id) value for the issue's type, status and priority against the
        // list to get the display name for the issue type, status and priority.
        org.apache.abdera.model.Element linkElement = ((Entry) activityObject.getParentElement())
                .getFirstChild(new QName("http://www.w3.org/2005/Atom", "link"));

        // Just in case
        if (linkElement == null) {
            LOGGER.error("Unable to find the link of the Jira activity entry for: "
                    + JSONUtils.xmlToJSON(activityObject.toString()));
            return;
        }

        URL activityLink = getURLFromLinkElement(linkElement);

        // Just in case
        if (activityLink == null) {
            LOGGER.error("Unable to parse the link of the Jira activity:object for: "
                    + JSONUtils.xmlToJSON(activityObject.toString()));
            return;
        }

        String[] pathParts = activityLink.getPath().split("/");
        String issueId = pathParts[pathParts.length - 1];

        if (!issueId.contains("-")) {
            // Not much we can do at this point but log the problem
            LOGGER.error("Unable to get the Jira issue id from the following url: " + activityLink.toString());
            return;
        }

        try {
            List<org.w3c.dom.Element> allIssueDetails = getIssueDetails(issueId);

            if (allIssueDetails == null || allIssueDetails.size() == 0) {
                // Not much we can do at this point but log the problem
                LOGGER.error("Unexpected response when retrieving Jira issue details for " + issueId + " in the "
                        + projectKey + " project.");
                return;
            }

            // Our DOM parsing code is verbose, not my fault, but also very cautious.

            // Retrieve the issue details
            org.w3c.dom.Element issueDetails = allIssueDetails.get(0);
            NodeList issueDetailsPriorityNodes = issueDetails.getElementsByTagName("priority");
            NodeList issueDetailsStatusNodes = issueDetails.getElementsByTagName("status");
            NodeList issueDetailsTypeNodes = issueDetails.getElementsByTagName("type");

            // Validate the responses
            if (issueDetailsPriorityNodes == null || issueDetailsPriorityNodes.getLength() == 0
                    || issueDetailsStatusNodes == null || issueDetailsStatusNodes.getLength() == 0
                    || issueDetailsTypeNodes == null || issueDetailsTypeNodes.getLength() == 0) {
                // Not much we can do at this point but log the problem
                LOGGER.error("Unexpected response when retrieving Jira issue detail priority/status/type for "
                        + issueId + " in the " + projectKey + " project.");
                return;
            }

            // These represent the actual low-level identifiers for issue priority, status and type.  These will
            // be compared against the priorites, statuses and types below.
            String issueDetailsPriority = issueDetailsPriorityNodes.item(0).getTextContent();
            String issueDetailsStatus = issueDetailsStatusNodes.item(0).getTextContent();
            String issueDetailsType = issueDetailsTypeNodes.item(0).getTextContent();

            // Retrieve the issue priorities, statuses and types.
            List<org.w3c.dom.Element> issuePriorities = getIssuePriorities();
            List<org.w3c.dom.Element> issueStatuses = getIssueStatuses();
            List<org.w3c.dom.Element> issueTypes = getIssueTypes();
            List<org.w3c.dom.Element> subTaskIssueTypes = getSubTaskIssueTypes();

            // Validate the responses
            if (issuePriorities == null || issuePriorities.size() == 0 || issueStatuses == null
                    || issueStatuses.size() == 0 || issueTypes == null || issueTypes.size() == 0
                    || subTaskIssueTypes == null) {
                // Not much we can do at this point but log the problem
                LOGGER.error("Unexpected response when retrieving Jira issue priority/status/type for " + issueId
                        + " in the " + projectKey + " project.");
                return;
            }

            // Get the priority and add the hashtag
            String issuePriorityName = getNameForId(issuePriorities, issueDetailsPriority);
            String issueStatusName = getNameForId(issueStatuses, issueDetailsStatus);
            String issueTypeName = getNameForId(issueTypes, issueDetailsType);
            String subTaskIssueTypeName = getNameForId(subTaskIssueTypes, issueDetailsType);

            if (issueTypeName == null && subTaskIssueTypeName == null) {
                // Not much we can do at this point but log the problem
                LOGGER.error("Unable to get the issue type name for id " + issueDetailsType + " of the " + issueId
                        + " issue in the " + projectKey + " project.");
            } else {
                hashtags.add(
                        removeIllegalHashtagCharacters("#" + (issueTypeName != null ? issueTypeName.toLowerCase()
                                : subTaskIssueTypeName.toLowerCase())));
            }

            if (issuePriorityName == null) {
                // Not much we can do at this point but log the problem
                LOGGER.error("Unable to get the issue priority name for id " + issueDetailsPriority + " of the "
                        + issueId + " issue in the " + projectKey + " project.");
            } else {
                hashtags.add(removeIllegalHashtagCharacters("#" + issuePriorityName.toLowerCase()));
            }

            if (issueStatusName == null) {
                // Not much we can do at this point but log the problem
                LOGGER.error("Unable to get the issue status name for id " + issueDetailsStatus + " of the "
                        + issueId + " issue in the " + projectKey + " project.");
            } else {
                hashtags.add(removeIllegalHashtagCharacters("#" + issueStatusName.toLowerCase()));
            }

            // Would be nice to get the labels on the issue but there doesn't appear to be an API to retrieve them
        } catch (SOAPException e) {
            LOGGER.error("Unable to get the issue details for " + issueId + " in the " + projectKey + " project.");
        }
    }

    /**
     * Makes a call to Jira via REST.
     *
     * @param url        the Jira REST URL to make a call to
     * @param maxResults the maximum number of results to return
     * @param useCache   specifies whether or not to use cache
     * @param anonymous  specifies whether or not to make the request anonymously
     * @return a list of JSONObjects or an empty list if the response had no content
     * @throws InvalidCredentialsException if the connection associated with this client has invalid credentials
     * @throws IOException                 if anything goes wrong making the actual request
     */
    @SuppressWarnings("unchecked")
    private List<JSONObject> makeRESTRequest(String url, int maxResults, boolean useCache, boolean anonymous)
            throws InvalidCredentialsException, IOException {
        // Caching in Jira is a little harder than in GitHub because we have to make anonymous calls sometimes.
        // That being said, our cache key is the username-url instead of just the URL.
        String cacheKey = (getConnectionCredentials().getIdentity() + "-" + url);
        Object objectFromCache = (useCache ? requestCache.getIfPresent(cacheKey) : null);
        List<JSONObject> response = (objectFromCache != null ? (List<JSONObject>) objectFromCache : null);
        String rUsername = (anonymous ? null : getConnectionCredentials().getIdentity());
        String rPassword = (anonymous ? null : getConnectionCredentials().getCredential());

        // Quick return if there was an entry in the cache
        if (response != null) {
            debugLog(LOGGER, "  (From cache)");
            return response;
        } else {
            response = new ArrayList<>();
        }

        JSONArray rawResponse = new JSONArray();

        try {
            // Try to parse as a JSONArray knowing that it might be a JSONObject
            rawResponse = JSONArray.fromObject(HTTPUtils.openUrl(url, HttpMethod.GET, null,
                    MediaType.APPLICATION_JSON, rUsername, rPassword, null, null));
        } catch (JSONException e) {
            try {
                // Try to parse as a JSONObject
                JSONObject rawObject = JSONObject.fromObject(HTTPUtils.openUrl(url, HttpMethod.GET, null,
                        MediaType.APPLICATION_JSON, rUsername, rPassword, null, null));

                rawResponse.add(rawObject);
            } catch (JSONException e2) {
                // Fail
                return null;
            }
        }

        for (Object anArrayResponse : rawResponse) {
            if (response.size() < maxResults) {
                response.add(JSONObject.fromObject(anArrayResponse));
            }

            if (response.size() == maxResults) {
                break;
            }
        }

        if (useCache) {
            requestCache.put(cacheKey, response);
        }

        return response;
    }

    private String concatRawTitleAndContent(Entry entry) {
        StringBuilder sb = new StringBuilder();
        if (entry.getTitle() != null) {
            sb.append(entry.getTitle());
        }
        if (entry.getContent() != null) {
            sb.append(entry.getContent());
        }
        return sb.toString();
    }
}