Java tutorial
/* * JBoss, Home of Professional Open Source. * Copyright 2015, Red Hat, Inc., and individual contributors * as indicated by the @author tags. See the copyright.txt file in the * distribution for a full listing of individual contributors. * * This is free software; you can redistribute it and/or modify it * under the terms of the GNU Lesser General Public License as * published by the Free Software Foundation; either version 2.1 of * the License, or (at your option) any later version. * * This software is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this software; if not, write to the Free * Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA * 02110-1301 USA, or see the FSF site: http://www.fsf.org. */ package org.jboss.set.aphrodite.issue.trackers.jira; import static org.jboss.set.aphrodite.issue.trackers.jira.JiraFields.API_ISSUE_PATH; import static org.jboss.set.aphrodite.issue.trackers.jira.JiraFields.BROWSE_ISSUE_PATH; import static org.jboss.set.aphrodite.issue.trackers.jira.JiraFields.FLAG_MAP; import static org.jboss.set.aphrodite.issue.trackers.jira.JiraFields.PROJECTS_ISSUE_PATTERN; import static org.jboss.set.aphrodite.issue.trackers.jira.JiraFields.TARGET_RELEASE; import static org.jboss.set.aphrodite.issue.trackers.jira.JiraFields.getJiraTransition; import java.io.IOException; import java.net.URI; import java.net.URL; import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; import java.util.stream.StreamSupport; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.jboss.set.aphrodite.common.Utils; import org.jboss.set.aphrodite.config.IssueTrackerConfig; import org.jboss.set.aphrodite.config.TrackerType; import org.jboss.set.aphrodite.domain.Comment; import org.jboss.set.aphrodite.domain.Flag; import org.jboss.set.aphrodite.domain.Issue; import org.jboss.set.aphrodite.domain.SearchCriteria; import org.jboss.set.aphrodite.issue.trackers.common.AbstractIssueTracker; import org.jboss.set.aphrodite.spi.AphroditeException; import org.jboss.set.aphrodite.spi.NotFoundException; import com.atlassian.jira.rest.client.api.IssueRestClient; import com.atlassian.jira.rest.client.api.JiraRestClient; import com.atlassian.jira.rest.client.api.JiraRestClientFactory; import com.atlassian.jira.rest.client.api.SearchRestClient; import com.atlassian.jira.rest.client.api.domain.Filter; import com.atlassian.jira.rest.client.api.domain.IssueLink; import com.atlassian.jira.rest.client.api.domain.IssueLinkType.Direction; import com.atlassian.jira.rest.client.api.domain.Project; import com.atlassian.jira.rest.client.api.domain.SearchResult; import com.atlassian.jira.rest.client.api.domain.Transition; import com.atlassian.jira.rest.client.api.domain.Version; import com.atlassian.jira.rest.client.api.domain.input.IssueInput; import com.atlassian.jira.rest.client.api.domain.input.LinkIssuesInput; import com.atlassian.jira.rest.client.api.domain.input.TransitionInput; import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory; import com.atlassian.util.concurrent.Promise; /** * An implementation of the <code>IssueTrackerService</code> for the JIRA issue tracker. * * @author Ryan Emerson */ public class JiraIssueTracker extends AbstractIssueTracker { static final Pattern JIRAFIXVERSION = Pattern.compile("(\\d\\.)(\\d\\.)(\\d+).GA"); private static final Log LOG = LogFactory.getLog(JiraIssueTracker.class); private final IssueWrapper WRAPPER = new IssueWrapper(); private final JiraQueryBuilder queryBuilder = new JiraQueryBuilder(); private JiraRestClient restClient; public JiraIssueTracker() { super(TrackerType.JIRA); } @Override public boolean init(IssueTrackerConfig config) { boolean parentInitiated = super.init(config); if (!parentInitiated) return false; try { JiraRestClientFactory factory = new AsynchronousJiraRestClientFactory(); URI jiraServerUri = baseUrl.toURI(); restClient = factory.createWithBasicHttpAuthentication(jiraServerUri, config.getUsername(), config.getPassword()); //work around to auth. No need to check number, its just garbage or general login failure number, not related to our //activity. restClient.getSessionClient().getCurrentSession().get().getLoginInfo().getFailedLoginCount(); } catch (Exception e) { Utils.logException(LOG, e); return false; } return true; } @Override public Issue getIssue(URL url) throws NotFoundException { String issueKey = getIssueKey(url); List<IssueRestClient.Expandos> expandos = createExpandos(); try { checkHost(url); com.atlassian.jira.rest.client.api.domain.Issue issue = restClient.getIssueClient() .getIssue(issueKey, expandos).get(); return WRAPPER.jiraIssueToIssue(url, issue); } catch (InterruptedException e) { throw new NotFoundException( "Something interrupted the execution when trying to retrieve issue " + issueKey, e); } catch (ExecutionException e) { throw new NotFoundException("Unable to retrieve issue with id: " + issueKey, e); } } private List<IssueRestClient.Expandos> createExpandos() { List<IssueRestClient.Expandos> expandos = new ArrayList<>(); expandos.add(IssueRestClient.Expandos.CHANGELOG); return expandos; } private com.atlassian.jira.rest.client.api.domain.Issue getIssue(Issue issue) throws NotFoundException { String trackerId = issue.getTrackerId().orElse(getIssueKey(issue.getURL())); return getIssue(trackerId); } private com.atlassian.jira.rest.client.api.domain.Issue getIssue(String trackerId) throws NotFoundException { try { return restClient.getIssueClient().getIssue(trackerId).get(); } catch (Exception e) { throw new NotFoundException(e); } } @Override public List<Issue> getIssues(Collection<URL> urls) { urls = filterUrlsByHost(urls); if (urls.isEmpty()) return new ArrayList<>(); List<String> ids = new ArrayList<>(); for (URL url : urls) { try { ids.add(getIssueKey(url)); } catch (NotFoundException e) { if (LOG.isWarnEnabled()) LOG.warn("Unable to extract trackerId from: " + url); } } String jql = queryBuilder.getMultipleIssueJQL(ids); return searchIssues(jql, ids.size()); } @Override public List<Issue> searchIssues(SearchCriteria searchCriteria) { String jql = queryBuilder.getSearchJQL(searchCriteria); int maxResults = searchCriteria.getMaxResults().orElse(config.getDefaultIssueLimit()); return searchIssues(jql, maxResults); } private List<Issue> searchIssues(String jql, int maxResults) { try { Set<String> fields = new HashSet<>(); fields.add("*all"); return paginateResults(restClient.getSearchClient(), jql, fields, maxResults); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } } private static int NB_TOTAL_ISSUE_NOT_INITIATED = -1; private List<Issue> paginateResults(SearchRestClient searchClient, String jql, Set<String> fields, int maxResults) throws InterruptedException, ExecutionException { List<Issue> issues = new ArrayList<>(); int startPosition = 0; int nbTotalIssue = NB_TOTAL_ISSUE_NOT_INITIATED; if (LOG.isDebugEnabled()) LOG.debug("Max Results:" + maxResults); do { if (LOG.isDebugEnabled()) LOG.debug("Start Position:" + startPosition); SearchResult result = searchClient.searchJql(jql, maxResults, startPosition, fields).get(); if (nbTotalIssue == NB_TOTAL_ISSUE_NOT_INITIATED) { nbTotalIssue = result.getTotal(); if (LOG.isDebugEnabled()) LOG.debug("Total Issues in result:" + nbTotalIssue); } result.getIssues().forEach(issue -> issues.add(WRAPPER.jiraSearchIssueToIssue(baseUrl, issue))); startPosition += maxResults; } while (startPosition < nbTotalIssue); if (LOG.isDebugEnabled()) LOG.debug("Total issues:" + issues.size()); return issues; } @Override public List<Issue> searchIssuesByFilter(URL filterUrl) throws NotFoundException { String jql = getJQLFromFilter(filterUrl); return searchIssues(jql, config.getDefaultIssueLimit()); } private String getJQLFromFilter(URL filterUrl) throws NotFoundException { try { // url type example https://issues.jboss.org/rest/api/latest/filter/12322199 SearchRestClient searchClient = restClient.getSearchClient(); Filter filter = searchClient.getFilter(filterUrl.toURI()).get(); return filter.getJql(); } catch (Exception e) { throw new NotFoundException("Unable to retrieve filter with url: " + filterUrl, e); } } /** * Known limitations: * - Jira api does not allow an issue type to be update (WTF?) * - Jira api does not allow project to be changed */ @Override public boolean updateIssue(Issue issue) throws NotFoundException, AphroditeException { try { checkHost(issue.getURL()); com.atlassian.jira.rest.client.api.domain.Issue jiraIssue = getIssue(issue); Project project = restClient.getProjectClient().getProject(jiraIssue.getProject().getSelf()).claim(); IssueInput update = WRAPPER.issueToFluentUpdate(issue, jiraIssue, project); IssueRestClient issueClient = restClient.getIssueClient(); issueClient.updateIssue(jiraIssue.getKey(), update).claim(); if (!JiraFields.hasSameIssueStatus(issue, jiraIssue)) { String transition = getJiraTransition(issue, jiraIssue); for (Transition t : issueClient.getTransitions(jiraIssue).get()) { if (t.getName().equals(transition)) { issueClient.transition(jiraIssue, new TransitionInput(t.getId())).claim(); } } } // only supports add for (LinkIssuesInput linkIssuesInput : calculateNewLinks(issue, jiraIssue)) { issueClient.linkIssue(linkIssuesInput).claim(); } return true; } catch (ExecutionException | InterruptedException e) { throw new AphroditeException(getUpdateErrorMessage(issue, e), e); } } private List<LinkIssuesInput> calculateNewLinks(Issue issue, com.atlassian.jira.rest.client.api.domain.Issue jiraIssue) { // When jiraIssueLinks is null, this means that issue links have been disabled, so return an empty list Iterable<IssueLink> jiraIssueLinks = jiraIssue.getIssueLinks(); if (jiraIssueLinks == null) return new ArrayList<>(); // Process the existing IssueLinks and retrieve their Issue keys List<IssueLink> tmp = StreamSupport.stream(jiraIssueLinks.spliterator(), false) .collect(Collectors.toList()); List<String> inbound = getExistingIssueLinkKeys(tmp, Direction.INBOUND); List<String> outbound = getExistingIssueLinkKeys(tmp, Direction.OUTBOUND); return Stream.concat( createIssueLinks(issue, inbound, e -> new LinkIssuesInput(e, jiraIssue.getKey(), "Dependency")), createIssueLinks(issue, outbound, e -> new LinkIssuesInput(jiraIssue.getKey(), e, "Dependency"))) .collect(Collectors.toList()); } private Stream<LinkIssuesInput> createIssueLinks(Issue issue, List<String> existingLinks, Function<String, LinkIssuesInput> createLink) { return issue.getBlocks().stream().map(this::toKey).filter(e -> !e.isEmpty() && !existingLinks.contains(e)) .map(createLink); } private List<String> getExistingIssueLinkKeys(List<IssueLink> issueLinks, Direction linkDirection) { return issueLinks.stream().filter(link -> link.getIssueLinkType().getDirection().equals(linkDirection)) .filter(link -> link.getIssueLinkType().getName().equals("Dependency")) .map(IssueLink::getTargetIssueKey).collect(Collectors.toList()); } private String toKey(URL url) { try { return getIssueKey(url); } catch (NotFoundException e) { return ""; } } @Override public void addCommentToIssue(Issue issue, Comment comment) throws NotFoundException { super.addCommentToIssue(issue, comment); postComment(issue, comment); } private void postComment(Issue issue, Comment comment) throws NotFoundException { if (comment.isPrivate()) Utils.logWarnMessage(LOG, "Private comments are not currently supported by " + getClass().getName()); com.atlassian.jira.rest.client.api.domain.Issue jiraIssue = getIssue(issue); com.atlassian.jira.rest.client.api.domain.Comment c = com.atlassian.jira.rest.client.api.domain.Comment .valueOf(comment.getBody()); restClient.getIssueClient().addComment(jiraIssue.getCommentsUri(), c).claim(); } @Override public boolean addCommentToIssue(Map<Issue, Comment> commentMap) { commentMap = filterIssuesByHost(commentMap); List<CompletableFuture<Boolean>> requests = commentMap.entrySet().stream() .map(entry -> CompletableFuture.supplyAsync( () -> postCommentAndLogExceptions(entry.getKey(), entry.getValue()), executorService)) .collect(Collectors.toList()); return requests.stream().map(CompletableFuture::join).noneMatch(failed -> !failed); } @Override public boolean addCommentToIssue(Collection<Issue> issues, Comment comment) { issues = filterIssuesByHost(issues); List<CompletableFuture<Boolean>> requests = issues .stream().map(issue -> CompletableFuture .supplyAsync(() -> postCommentAndLogExceptions(issue, comment), executorService)) .collect(Collectors.toList()); return requests.stream().map(CompletableFuture::join).noneMatch(failed -> !failed); } private boolean postCommentAndLogExceptions(Issue issue, Comment comment) { try { postComment(issue, comment); return true; } catch (NotFoundException e) { Utils.logException(LOG, e); return false; } } @Override public Log getLog() { return LOG; } private String getIssueKey(URL url) throws NotFoundException { String path = correctPath(url.getPath()); boolean api = path.contains(API_ISSUE_PATH); boolean browse = path.contains(BROWSE_ISSUE_PATH); if (!(api || browse)) throw new NotFoundException( "The URL path must be of the form '" + API_ISSUE_PATH + "' OR '" + BROWSE_ISSUE_PATH + "'"); return api ? path.substring(API_ISSUE_PATH.length()) : path.substring(BROWSE_ISSUE_PATH.length()); } private String correctPath(String path) { Matcher m = PROJECTS_ISSUE_PATTERN.matcher(path); if (m.find()) { return m.replaceFirst(BROWSE_ISSUE_PATH); } return path; } private String getUpdateErrorMessage(Issue issue, Exception e) { String msg = e.getMessage(); if (msg.contains("does not exist or read-only")) { for (Map.Entry<Flag, String> entry : FLAG_MAP.entrySet()) { if (msg.contains(entry.getValue())) { String retMsg = "Flag '%1$s' set in Issue.stage cannot be set for %2$s '%3$s'"; return getOptionalErrorMessage(retMsg, issue.getProduct(), entry.getKey(), issue.getURL()); } } if (msg.contains(TARGET_RELEASE)) { String retMsg = "Release.milestone cannot be set for %2$s ''%3$s'"; return getOptionalErrorMessage(retMsg, issue.getProduct(), null, issue.getURL()); } } return null; } private String getOptionalErrorMessage(String template, Optional<?> optional, Object val, URL url) { if (optional.isPresent()) return String.format(template, val, "issues in project", optional.get()); else return String.format(template, val, "issue at ", url); } @Override public void destroy() { try { restClient.close(); } catch (IOException e) { LOG.warn("destroyin jira issue tracker", e); } } @Override public boolean isCPReleased(String cpVersion) { // For Jira, only accept GA version format x.y.z.GA, e.g. 7.1.2.GA // ignore CR version like 7.0.7.CR3 Matcher matcher = JIRAFIXVERSION.matcher(cpVersion); if (!matcher.matches()) { return false; } Promise<Project> promise = restClient.getProjectClient().getProject("JBEAP"); Project project = promise.claim(); Optional<Version> version = StreamSupport.stream(project.getVersions().spliterator(), false) .filter(v -> v.getName().equals(cpVersion)).findAny(); if (version.isPresent()) { return version.get().isReleased(); } return false; } }