/*
* 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();
}
}