package hudson.plugins.jira;
import com.atlassian.jira.rest.client.api.JiraRestClient;
import com.atlassian.jira.rest.client.api.RestClientException;
import com.atlassian.jira.rest.client.api.domain.Issue;
import com.atlassian.jira.rest.client.api.domain.Version;
import com.atlassian.jira.rest.client.internal.async.AsynchronousJiraRestClientFactory;
import com.google.common.base.*;
import com.google.common.base.Objects;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import hudson.Extension;
import hudson.Util;
import hudson.model.AbstractDescribableImpl;
import hudson.model.Descriptor;
import hudson.model.Job;
import hudson.plugins.jira.model.JiraIssue;
import hudson.plugins.jira.model.JiraVersion;
import hudson.util.FormValidation;
import hudson.util.Secret;
import org.joda.time.DateTime;
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.QueryParameter;
import javax.annotation.CheckForNull;
import javax.annotation.Nullable;
import java.io.IOException;
import java.io.PrintStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.*;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.logging.Level;
import java.util.logging.Logger;
import java.util.regex.Pattern;
import static org.apache.commons.lang.StringUtils.isEmpty;
import static org.apache.commons.lang.StringUtils.isNotEmpty;
/**
* Represents an external JIRA installation and configuration
* needed to access this JIRA.
*
* @author Kohsuke Kawaguchi
*/
public class JiraSite extends AbstractDescribableImpl<JiraSite> {
private static final Logger LOGGER = Logger.getLogger(JiraSite.class.getName());
/**
* Regexp pattern that identifies JIRA issue token.
* If this pattern changes help pages (help-issue-pattern_xy.html) must be updated
* First char must be a letter, then at least one letter, digit or underscore.
* See issue JENKINS-729, JENKINS-4092
*/
public static final Pattern DEFAULT_ISSUE_PATTERN = Pattern.compile("([a-zA-Z][a-zA-Z0-9_]+-[1-9][0-9]*)([^.]|\\.[^0-9]|\\.$|$)");
/**
* Default rest api client calls timeout, in seconds
* See issue JENKINS-31113
*/
public static final int DEFAULT_TIMEOUT = 10;
/**
* URL of JIRA for Jenkins access, like <tt>http://jira.codehaus.org/</tt>.
* Mandatory. Normalized to end with '/'
*/
public final URL url;
/**
* URL of JIRA for normal access, like <tt>http://jira.codehaus.org/</tt>.
* Mandatory. Normalized to end with '/'
*/
public final URL alternativeUrl;
/**
* Jira requires HTTP Authentication for login
*/
public final boolean useHTTPAuth;
/**
* User name needed to login. Optional.
*/
public final String userName;
/**
* Password needed to login. Optional.
*/
public final Secret password;
/**
* Group visibility to constrain the visibility of the added comment. Optional.
*/
public final String groupVisibility;
/**
* Role visibility to constrain the visibility of the added comment. Optional.
*/
public final String roleVisibility;
/**
* True if this JIRA is configured to allow Confluence-style Wiki comment.
*/
public final boolean supportsWikiStyleComment;
/**
* to record scm changes in jira issue
*
* @since 1.21
*/
public final boolean recordScmChanges;
/**
* user defined pattern
*
* @since 1.22
*/
private final String userPattern;
private transient Pattern userPat;
/**
* updated jira issue for all status
*
* @since 1.22
*/
public final boolean updateJiraIssueForAllStatus;
/**
* timeout used when calling jira rest api, in seconds
*/
public Integer timeout;
/**
* Configuration for formatting (date -> text) in jira comments.
*/
private String dateTimePattern;
/**
* To add scm entry change date and time in jira comments.
*
*/
private Boolean appendChangeTimestamp;
/**
* List of project keys (i.e., "MNG" portion of "MNG-512"),
* last time we checked. Copy on write semantics.
*/
// TODO: seems like this is never invalidated (never set to null)
// should we implement to invalidate this (say every hour)?
private transient volatile Set<String> projects;
private transient Cache<String, Issue> issueCache = makeIssueCache();
/**
* Used to guard the computation of {@link #projects}
*/
private transient Lock projectUpdateLock = new ReentrantLock();
private transient JiraSession jiraSession = null;
@DataBoundConstructor
public JiraSite(URL url, URL alternativeUrl, String userName, String password, boolean supportsWikiStyleComment, boolean recordScmChanges, String userPattern,
boolean updateJiraIssueForAllStatus, String groupVisibility, String roleVisibility, boolean useHTTPAuth) {
if (url != null && !url.toExternalForm().endsWith("/"))
try {
url = new URL(url.toExternalForm() + "/");
} catch (MalformedURLException e) {
throw new AssertionError(e);
}
if (alternativeUrl != null && !alternativeUrl.toExternalForm().endsWith("/"))
try {
alternativeUrl = new URL(alternativeUrl.toExternalForm() + "/");
} catch (MalformedURLException e) {
throw new AssertionError(e);
}
this.url = url;
this.timeout = JiraSite.DEFAULT_TIMEOUT;
this.alternativeUrl = alternativeUrl;
this.userName = Util.fixEmpty(userName);
this.password = Secret.fromString(Util.fixEmpty(password));
this.supportsWikiStyleComment = supportsWikiStyleComment;
this.recordScmChanges = recordScmChanges;
this.userPattern = Util.fixEmpty(userPattern);
if (this.userPattern != null) {
this.userPat = Pattern.compile(this.userPattern);
} else {
this.userPat = null;
}
this.updateJiraIssueForAllStatus = updateJiraIssueForAllStatus;
this.groupVisibility = Util.fixEmpty(groupVisibility);
this.roleVisibility = Util.fixEmpty(roleVisibility);
this.useHTTPAuth = useHTTPAuth;
this.jiraSession = null;
}
@DataBoundSetter
public void setTimeout(Integer timeout) {
this.timeout = timeout;
}
@DataBoundSetter
public void setDateTimePattern(String dateTimePattern) {
this.dateTimePattern = dateTimePattern;
}
@DataBoundSetter
public void setAppendChangeTimestamp(Boolean appendChangeTimestamp) {
this.appendChangeTimestamp = appendChangeTimestamp;
}
public String getDateTimePattern() {
return dateTimePattern;
}
public boolean isAppendChangeTimestamp() {
return this.appendChangeTimestamp != null && this.appendChangeTimestamp.booleanValue();
}
protected Object readResolve() {
projectUpdateLock = new ReentrantLock();
issueCache = makeIssueCache();
jiraSession = null;
return this;
}
private static Cache<String, Issue> makeIssueCache() {
return CacheBuilder.newBuilder().concurrencyLevel(2).expireAfterAccess(2, TimeUnit.MINUTES).build();
}
public String getName() {
return url.toExternalForm();
}
/**
* Gets a remote access session to this JIRA site.
* Creates one if none exists already.
*
* @return null if remote access is not supported.
*/
@Nullable
public JiraSession getSession() throws IOException {
if (jiraSession == null) {
jiraSession = createSession();
}
return jiraSession;
}
/**
* Creates a remote access session to this JIRA.
*
* @return null if remote access is not supported.
*/
protected JiraSession createSession() throws IOException {
if (userName == null || password == null)
return null; // remote access not supported
final URI uri;
try {
uri = url.toURI();
} catch (URISyntaxException e) {
LOGGER.warning("convert URL to URI error: " + e.getMessage());
throw new RuntimeException("failed to create JiraSession due to convert URI error");
}
LOGGER.fine("creating Jira Session: " + uri);
final JiraRestClient jiraRestClient = new AsynchronousJiraRestClientFactory()
.createWithBasicHttpAuthentication(uri, userName, password.getPlainText());
int usedTimeout = timeout != null ? timeout : JiraSite.DEFAULT_TIMEOUT;
return new JiraSession(this, new JiraRestService(uri, jiraRestClient, userName, password.getPlainText(), usedTimeout));
}
/**
* @return the server URL
*/
@Nullable
public URL getUrl() {
return Objects.firstNonNull(this.url, this.alternativeUrl);
}
/**
* Computes the URL to the given issue.
*/
public URL getUrl(JiraIssue issue) throws IOException {
return getUrl(issue.getKey());
}
/**
* Computes the URL to the given issue.
*/
public URL getUrl(String id) throws MalformedURLException {
return new URL(url, "browse/" + id.toUpperCase());
}
/**
* Computes the alternative link URL to the given issue.
*/
public URL getAlternativeUrl(String id) throws MalformedURLException {
return alternativeUrl == null ? null : new URL(alternativeUrl, "browse/" + id.toUpperCase());
}
/**
* Gets the user-defined issue pattern if any.
*
* @return the pattern or null
*/
public Pattern getUserPattern() {
if (userPattern == null) {
return null;
}
if (userPat == null) {
// We don't care about any thread race- or visibility issues here.
// The worst thing which could happen, is that the pattern
// is compiled multiple times.
userPat = Pattern.compile(userPattern);
}
return userPat;
}
public Pattern getIssuePattern() {
if (getUserPattern() != null) {
return getUserPattern();
}
return DEFAULT_ISSUE_PATTERN;
}
/**
* Gets the list of project IDs in this JIRA.
* This information could be bit old, or it can be null.
*/
public Set<String> getProjectKeys() {
if (projects == null) {
try {
if (projectUpdateLock.tryLock(3, TimeUnit.SECONDS)) {
try {
if (projects == null) {
JiraSession session = getSession();
if (session != null) {
projects = Collections.unmodifiableSet(session.getProjectKeys());
}
}
} catch (IOException e) {
// in case of error, set empty set to avoid trying the same thing repeatedly.
LOGGER.log(Level.WARNING, "Failed to obtain JIRA project list", e);
} finally {
projectUpdateLock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // process this interruption later
}
}
// fall back to empty if failed to talk to the server
Set<String> p = projects;
if (p == null) {
return Collections.emptySet();
}
return p;
}
/**
* Gets the effective {@link JiraSite} associated with the given project.
*
* @return null
* if no such was found.
*/
public static JiraSite get(Job<?, ?> p) {
JiraProjectProperty jpp = p.getProperty(JiraProjectProperty.class);
if (jpp != null) {
JiraSite site = jpp.getSite();
if (site != null) {
return site;
}
}
// none is explicitly configured. try the default ---
// if only one is configured, that must be it.
JiraSite[] sites = JiraProjectProperty.DESCRIPTOR.getSites();
if (sites.length == 1) {
return sites[0];
}
return null;
}
/**
* Checks if the given JIRA id will be likely to exist in this issue tracker.
* This method checks whether the key portion is a valid key (except that
* it can potentially use stale data). Number portion is not checked at all.
*
* @deprecated Use getIssue instead
* @param id String like MNG-1234
*/
@Deprecated
public boolean existsIssue(String id) {
int idx = id.indexOf('-');
if (idx == -1) {
return false;
}
Set<String> keys = getProjectKeys();
return keys.contains(id.substring(0, idx).toUpperCase());
}
/**
* Returns the remote issue with the given id or <code>null</code> if it wasn't found.
*/
@CheckForNull
public JiraIssue getIssue(final String id) throws IOException {
try {
Issue issue = issueCache.get(id, new Callable<Issue>() {
public Issue call() throws Exception {
JiraSession session = getSession();
Issue issue = null;
if (session != null) {
issue = session.getIssue(id);
}
return issue != null ? issue : null;
}
});
if (issue == null) {
return null;
}
return new JiraIssue(issue);
} catch (ExecutionException e) {
throw new IOException(e);
}
}
/**
* Release a given version.
*
* @param projectKey The Project Key
* @param versionName The name of the version
* @throws IOException
*/
public void releaseVersion(String projectKey, String versionName) throws IOException {
JiraSession session = getSession();
if (session != null) {
List<Version> versions = session.getVersions(projectKey);
if (versions == null || versions.isEmpty()) {
return;
}
for (Version version : versions) {
if (version.getName().equals(versionName)) {
Version releaseVersion = new Version(version.getSelf(), version.getId(), version.getName(),
version.getDescription(), version.isArchived(), true, new DateTime());
session.releaseVersion(projectKey, releaseVersion);
return;
}
}
}
}
/**
* Returns all versions for the given project key.
*
* @param projectKey Project Key
* @return A set of JiraVersions
* @throws IOException
*/
public Set<JiraVersion> getVersions(String projectKey) throws IOException {
JiraSession session = getSession();
if (session == null) {
LOGGER.warning("JIRA session could not be established");
return Collections.emptySet();
}
List<Version> versions = session.getVersions(projectKey);
if (versions == null) {
return Collections.emptySet();
}
Set<JiraVersion> versionsSet = new HashSet<JiraVersion>(versions.size());
for (Version version : versions) {
versionsSet.add(new JiraVersion(version));
}
return versionsSet;
}
/**
* Generates release notes for a given version.
*
* @param projectKey
* @param versionName
* @return release notes
* @throws IOException
*/
public String getReleaseNotesForFixVersion(String projectKey, String versionName) throws IOException {
return getReleaseNotesForFixVersion(projectKey, versionName, "");
}
/**
* Generates release notes for a given version.
*
* @param projectKey
* @param versionName
* @param filter Additional JQL Filter. Example: status in (Resolved,Closed)
* @return release notes
* @throws IOException
*/
public String getReleaseNotesForFixVersion(String projectKey, String versionName, String filter) throws IOException {
JiraSession session = getSession();
if (session == null) {
LOGGER.warning("JIRA session could not be established");
return "";
}
List<Issue> issues = session.getIssuesWithFixVersion(projectKey, versionName, filter);
if (issues == null) {
return "";
}
Map<String, Set<String>> releaseNotes = new HashMap<String, Set<String>>();
for (Issue issue : issues) {
String key = issue.getKey();
String summary = issue.getSummary();
String status = issue.getStatus().getName();
String type = "UNKNOWN";
if (issue.getIssueType() != null && issue.getIssueType().getName() != null) {
type = issue.getIssueType().getName();
}
Set<String> issueSet;
if (!releaseNotes.containsKey(type)) {
issueSet = new HashSet<String>();
releaseNotes.put(type, issueSet);
} else {
issueSet = releaseNotes.get(type);
}
issueSet.add(String.format(" - [%s] %s (%s)", key, summary, status));
}
StringBuilder sb = new StringBuilder();
for (String type : releaseNotes.keySet()) {
sb.append(String.format("# %s\n", type));
for (String issue : releaseNotes.get(type)) {
sb.append(issue);
sb.append("\n");
}
}
return sb.toString();
}
/**
* Gets a set of issues that have the given fixVersion associated with them.
*
* <p>
* Kohsuke: this seems to fail if {@link JiraSite#useHTTPAuth} is on. What is the motivation behind JIRA site?
*
* @param projectKey The project key
* @param versionName The fixVersion
* @return A set of JiraIssues
* @throws IOException
*/
public Set<JiraIssue> getIssueWithFixVersion(String projectKey, String versionName) throws IOException {
JiraSession session = getSession();
if (session == null) {
return Collections.emptySet();
}
List<Issue> issues = session.getIssuesWithFixVersion(projectKey, versionName);
if (issues == null || issues.isEmpty()) {
return Collections.emptySet();
}
Set<JiraIssue> issueSet = new HashSet<JiraIssue>(issues.size());
for (Issue issue : issues) {
issueSet.add(new JiraIssue(issue));
}
return issueSet;
}
/**
* Migrates issues matching the jql query provided to a new fix version.
*
* @param projectKey The project key
* @param toVersion The new fixVersion
* @param query A JQL Query
* @throws IOException
*/
public void replaceFixVersion(String projectKey, String fromVersion, String toVersion, String query) throws IOException {
JiraSession session = getSession();
if (session == null) {
LOGGER.warning("JIRA session could not be established");
return;
}
session.replaceFixVersion(projectKey, fromVersion, toVersion, query);
}
/**
* Migrates issues matching the jql query provided to a new fix version.
*
* @param projectKey The project key
* @param versionName The new fixVersion
* @param query A JQL Query
* @throws IOException
*/
public void migrateIssuesToFixVersion(String projectKey, String versionName, String query) throws IOException {
JiraSession session = getSession();
if (session == null) {
LOGGER.warning("JIRA session could not be established");
return;
}
session.migrateIssuesToFixVersion(projectKey, versionName, query);
}
/**
* Adds new fix version to issues matching the jql.
*
* @param projectKey
* @param versionName
* @param query
* @throws IOException
*/
public void addFixVersionToIssue(String projectKey, String versionName, String query) throws IOException {
JiraSession session = getSession();
if (session == null) {
LOGGER.warning("JIRA session could not be established");
return;
}
session.addFixVersion(projectKey, versionName, query);
}
/**
* Progresses all issues matching the JQL search, using the given workflow action. Optionally
* adds a comment to the issue(s) at the same time.
*
* @param jqlSearch
* @param workflowActionName
* @param comment
* @param console
* @throws IOException
*/
public boolean progressMatchingIssues(String jqlSearch, String workflowActionName, String comment, PrintStream console) throws IOException {
JiraSession session = getSession();
if (session == null) {
LOGGER.warning("JIRA session could not be established");
console.println(Messages.FailedToConnect());
return false;
}
boolean success = true;
List<Issue> issues = session.getIssuesFromJqlSearch(jqlSearch);
if (isEmpty(workflowActionName)) {
console.println("[JIRA] No workflow action was specified, " +
"thus no status update will be made for any of the matching issues.");
}
for (Issue issue : issues) {
String issueKey = issue.getKey();
if (isNotEmpty(comment)) {
session.addComment(issueKey, comment, null, null);
}
if (isEmpty(workflowActionName)) {
continue;
}
Integer actionId = session.getActionIdForIssue(issueKey, workflowActionName);
if (actionId == null) {
LOGGER.fine(String.format("Invalid workflow action %s for issue %s; issue status = %s",
workflowActionName, issueKey, issue.getStatus()));
console.println(Messages.JiraIssueUpdateBuilder_UnknownWorkflowAction(issueKey, workflowActionName));
success = false;
continue;
}
String newStatus = session.progressWorkflowAction(issueKey, actionId);
console.println(String.format("[JIRA] Issue %s transitioned to \"%s\" due to action \"%s\".",
issueKey, newStatus, workflowActionName));
}
return success;
}
@Extension
public static class DescriptorImpl extends Descriptor<JiraSite> {
@Override
public String getDisplayName() {
return "JIRA Site";
}
/**
* Checks if the user name and password are valid.
*/
public FormValidation doValidate(@QueryParameter String userName,
@QueryParameter String url,
@QueryParameter String password,
@QueryParameter String groupVisibility,
@QueryParameter String roleVisibility,
@QueryParameter boolean useHTTPAuth,
@QueryParameter String alternativeUrl,
@QueryParameter Integer timeout) throws IOException {
url = Util.fixEmpty(url);
alternativeUrl = Util.fixEmpty(alternativeUrl);
URL mainURL, alternativeURL = null;
try{
if (url == null) {
return FormValidation.error("No URL given");
}
mainURL = new URL(url);
} catch (MalformedURLException e){
return FormValidation.error(String.format("Malformed URL (%s)", url), e );
}
try {
if (alternativeUrl != null) {
alternativeURL = new URL(alternativeUrl);
}
}catch (MalformedURLException e){
return FormValidation.error(String.format("Malformed alternative URL (%s)",alternativeUrl), e );
}
JiraSite site = new JiraSite(mainURL, alternativeURL, userName, password, false,
false, null, false, groupVisibility, roleVisibility, useHTTPAuth);
site.setTimeout(timeout);
try {
JiraSession session = site.createSession();
session.getMyPermissions();
return FormValidation.ok("Success");
} catch (RestClientException e) {
LOGGER.log(Level.WARNING, "Failed to login to JIRA at " + url, e);
}
return FormValidation.error("Failed to login to JIRA");
}
}
public void addVersion(String version, String projectKey) throws IOException {
JiraSession session = getSession();
if (session == null) {
LOGGER.warning("JIRA session could not be established");
return;
}
session.addVersion(version, projectKey);
}
}