| package com.intellij.tasks.jira; |
| |
| import com.google.gson.Gson; |
| import com.google.gson.JsonObject; |
| import com.intellij.openapi.diagnostic.Logger; |
| import com.intellij.openapi.util.Comparing; |
| import com.intellij.openapi.util.io.StreamUtil; |
| import com.intellij.openapi.util.text.StringUtil; |
| import com.intellij.openapi.vfs.CharsetToolkit; |
| import com.intellij.tasks.LocalTask; |
| import com.intellij.tasks.Task; |
| import com.intellij.tasks.TaskBundle; |
| import com.intellij.tasks.TaskState; |
| import com.intellij.tasks.impl.BaseRepositoryImpl; |
| import com.intellij.tasks.impl.gson.GsonUtil; |
| import com.intellij.tasks.jira.rest.JiraRestApi; |
| import com.intellij.tasks.jira.soap.JiraLegacyApi; |
| import com.intellij.util.ArrayUtil; |
| import com.intellij.util.containers.ContainerUtil; |
| import com.intellij.util.xmlb.annotations.Tag; |
| import org.apache.commons.httpclient.*; |
| import org.apache.commons.httpclient.cookie.CookiePolicy; |
| import org.apache.commons.httpclient.methods.GetMethod; |
| import org.apache.xmlrpc.CommonsXmlRpcTransport; |
| import org.apache.xmlrpc.XmlRpcClient; |
| import org.apache.xmlrpc.XmlRpcRequest; |
| import org.jetbrains.annotations.NotNull; |
| import org.jetbrains.annotations.Nullable; |
| |
| import java.io.InputStream; |
| import java.net.URL; |
| import java.util.Collections; |
| import java.util.Hashtable; |
| import java.util.List; |
| import java.util.Vector; |
| import java.util.regex.Pattern; |
| |
| /** |
| * @author Dmitry Avdeev |
| */ |
| @SuppressWarnings("UseOfObsoleteCollectionType") |
| @Tag("JIRA") |
| public class JiraRepository extends BaseRepositoryImpl { |
| |
| public static final Gson GSON = GsonUtil.createDefaultBuilder().create(); |
| private final static Logger LOG = Logger.getInstance(JiraRepository.class); |
| public static final String REST_API_PATH = "/rest/api/latest"; |
| |
| private static final boolean LEGACY_API_ONLY = Boolean.getBoolean("tasks.jira.legacy.api.only"); |
| private static final boolean BASIC_AUTH_ONLY = Boolean.getBoolean("tasks.jira.basic.auth.only"); |
| private static final boolean REDISCOVER_API = Boolean.getBoolean("tasks.jira.rediscover.api"); |
| |
| public static final Pattern JIRA_ID_PATTERN = Pattern.compile("\\p{javaUpperCase}+-\\d+"); |
| public static final String AUTH_COOKIE_NAME = "JSESSIONID"; |
| |
| /** |
| * Default JQL query |
| */ |
| private String mySearchQuery = TaskBundle.message("jira.default.query"); |
| |
| private JiraRemoteApi myApiVersion; |
| private String myJiraVersion; |
| |
| /** |
| * Serialization constructor |
| */ |
| @SuppressWarnings({"UnusedDeclaration"}) |
| public JiraRepository() { |
| setUseHttpAuthentication(true); |
| } |
| |
| public JiraRepository(JiraRepositoryType type) { |
| super(type); |
| // Use Basic authentication at the beginning of new session and disable then if needed |
| setUseHttpAuthentication(true); |
| } |
| |
| private JiraRepository(JiraRepository other) { |
| super(other); |
| mySearchQuery = other.mySearchQuery; |
| myJiraVersion = other.myJiraVersion; |
| if (other.myApiVersion != null) { |
| myApiVersion = other.myApiVersion.getType().createApi(this); |
| } |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (!super.equals(o)) return false; |
| if (!(o instanceof JiraRepository)) return false; |
| |
| JiraRepository repository = (JiraRepository)o; |
| |
| if (!Comparing.equal(mySearchQuery, repository.getSearchQuery())) return false; |
| if (!Comparing.equal(myJiraVersion, repository.getJiraVersion())) return false; |
| return true; |
| } |
| |
| |
| @NotNull |
| public JiraRepository clone() { |
| return new JiraRepository(this); |
| } |
| |
| public Task[] getIssues(@Nullable String query, int max, long since) throws Exception { |
| ensureApiVersionDiscovered(); |
| String resultQuery = StringUtil.notNullize(query); |
| if (isJqlSupported()) { |
| if (StringUtil.isNotEmpty(mySearchQuery) && StringUtil.isNotEmpty(query)) { |
| resultQuery = String.format("summary ~ '%s' and ", query) + mySearchQuery; |
| } |
| else if (StringUtil.isNotEmpty(query)) { |
| resultQuery = String.format("summary ~ '%s'", query); |
| } |
| else { |
| resultQuery = mySearchQuery; |
| } |
| } |
| List<Task> tasksFound = myApiVersion.findTasks(resultQuery, max); |
| // JQL matching doesn't allow to do something like "summary ~ query or key = query" |
| // and it will return error immediately. So we have to search in two steps to provide |
| // behavior consistent with e.g. YouTrack. |
| // looks like issue ID |
| if (query != null && JIRA_ID_PATTERN.matcher(query.trim()).matches()) { |
| Task task = findTask(query); |
| if (task != null) { |
| tasksFound = ContainerUtil.concat(true, tasksFound, task); |
| } |
| } |
| return ArrayUtil.toObjectArray(tasksFound, Task.class); |
| } |
| |
| @Nullable |
| @Override |
| public Task findTask(@NotNull String id) throws Exception { |
| ensureApiVersionDiscovered(); |
| return myApiVersion.findTask(id); |
| } |
| |
| @Override |
| public void updateTimeSpent(@NotNull LocalTask task, @NotNull String timeSpent, @NotNull String comment) throws Exception { |
| myApiVersion.updateTimeSpend(task, timeSpent, comment); |
| } |
| |
| @Nullable |
| @Override |
| public CancellableConnection createCancellableConnection() { |
| clearCookies(); |
| // TODO cancellable connection for XML_RPC? |
| return new CancellableConnection() { |
| @Override |
| protected void doTest() throws Exception { |
| ensureApiVersionDiscovered(); |
| myApiVersion.findTasks(mySearchQuery, 1); |
| } |
| |
| @Override |
| public void cancel() { |
| // do nothing for now |
| } |
| }; |
| } |
| |
| @NotNull |
| public JiraRemoteApi discoverApiVersion() throws Exception { |
| if (LEGACY_API_ONLY) { |
| LOG.info("Intentionally using only legacy JIRA API"); |
| return createLegacyApi(); |
| } |
| |
| String responseBody; |
| GetMethod method = new GetMethod(getRestUrl("serverInfo")); |
| try { |
| responseBody = executeMethod(method); |
| } |
| catch (Exception e) { |
| // probably JIRA version prior 4.2 |
| // It's not safe to call HttpMethod.getStatusCode() directly, because it will throw NPE |
| // if response was not received (connection lost etc.) and hasBeenUsed()/isRequestSent() are |
| // not the way to check it safely. |
| StatusLine status = method.getStatusLine(); |
| if (status != null && status.getStatusCode() == HttpStatus.SC_NOT_FOUND) { |
| return createLegacyApi(); |
| } |
| else { |
| throw e; |
| } |
| } |
| JsonObject object = GSON.fromJson(responseBody, JsonObject.class); |
| // when JIRA 4.x support will be dropped 'versionNumber' array in response |
| // may be used instead version string parsing |
| myJiraVersion = object.get("version").getAsString(); |
| JiraRestApi restApi = JiraRestApi.fromJiraVersion(myJiraVersion, this); |
| if (restApi == null) { |
| throw new Exception(TaskBundle.message("jira.failure.no.REST")); |
| } |
| return restApi; |
| } |
| |
| private JiraLegacyApi createLegacyApi() { |
| try { |
| XmlRpcClient client = new XmlRpcClient(getUrl()); |
| Vector<String> parameters = new Vector<String>(Collections.singletonList("")); |
| XmlRpcRequest request = new XmlRpcRequest("jira1.getServerInfo", parameters); |
| @SuppressWarnings("unchecked") Hashtable<String, Object> response = |
| (Hashtable<String, Object>)client.execute(request, new CommonsXmlRpcTransport(new URL(getUrl()), getHttpClient())); |
| if (response != null) { |
| myJiraVersion = (String)response.get("version"); |
| } |
| } |
| catch (Exception e) { |
| LOG.error("Cannot find out JIRA version via XML-RPC", e); |
| } |
| return new JiraLegacyApi(this); |
| } |
| |
| private void ensureApiVersionDiscovered() throws Exception { |
| if (myApiVersion == null || LEGACY_API_ONLY || REDISCOVER_API) { |
| myApiVersion = discoverApiVersion(); |
| } |
| } |
| |
| @NotNull |
| public String executeMethod(@NotNull HttpMethod method) throws Exception { |
| LOG.debug("URI: " + method.getURI()); |
| |
| HttpClient client = getHttpClient(); |
| // Fix for https://jetbrains.zendesk.com/agent/#/tickets/24566 |
| // See https://confluence.atlassian.com/display/ONDEMANDKB/Getting+randomly+logged+out+of+OnDemand for details |
| // IDEA-128824, IDEA-128706 Use cookie authentication only for JIRA on-Demand |
| // TODO Make JiraVersion more suitable for such checks |
| final boolean isJiraOnDemand = StringUtil.notNullize(myJiraVersion).contains("OD"); |
| if (isJiraOnDemand) { |
| LOG.info("Connecting to JIRA on-Demand. Cookie authentication is enabled unless 'tasks.jira.basic.auth.only' VM flag is used."); |
| } |
| if (BASIC_AUTH_ONLY || !isJiraOnDemand) { |
| // to override persisted settings |
| setUseHttpAuthentication(true); |
| } |
| else { |
| boolean enableBasicAuthentication = !(isRestApiSupported() && containsCookie(client, AUTH_COOKIE_NAME)); |
| if (enableBasicAuthentication != isUseHttpAuthentication()) { |
| LOG.info("Basic authentication for subsequent requests was " + (enableBasicAuthentication ? "enabled" : "disabled")); |
| } |
| setUseHttpAuthentication(enableBasicAuthentication); |
| } |
| |
| int statusCode = client.executeMethod(method); |
| LOG.debug("Status code: " + statusCode); |
| // may be null if 204 No Content received |
| final InputStream stream = method.getResponseBodyAsStream(); |
| String entityContent = stream == null ? "" : StreamUtil.readText(stream, CharsetToolkit.UTF8); |
| //TaskUtil.prettyFormatJsonToLog(LOG, entityContent); |
| // besides SC_OK, can also be SC_NO_CONTENT in issue transition requests |
| // see: JiraRestApi#setTaskStatus |
| //if (statusCode == HttpStatus.SC_OK || statusCode == HttpStatus.SC_NO_CONTENT) { |
| if (statusCode >= 200 && statusCode < 300) { |
| return entityContent; |
| } |
| clearCookies(); |
| if (method.getResponseHeader("Content-Type") != null) { |
| Header header = method.getResponseHeader("Content-Type"); |
| if (header.getValue().startsWith("application/json")) { |
| JsonObject object = GSON.fromJson(entityContent, JsonObject.class); |
| if (object.has("errorMessages")) { |
| String reason = StringUtil.join(object.getAsJsonArray("errorMessages"), " "); |
| // something meaningful to user, e.g. invalid field name in JQL query |
| LOG.warn(reason); |
| throw new Exception(TaskBundle.message("failure.server.message", reason)); |
| } |
| } |
| } |
| if (method.getResponseHeader("X-Authentication-Denied-Reason") != null) { |
| Header header = method.getResponseHeader("X-Authentication-Denied-Reason"); |
| // only in JIRA >= 5.x.x |
| if (header.getValue().startsWith("CAPTCHA_CHALLENGE")) { |
| throw new Exception(TaskBundle.message("jira.failure.captcha")); |
| } |
| } |
| if (statusCode == HttpStatus.SC_UNAUTHORIZED) { |
| throw new Exception(TaskBundle.message("failure.login")); |
| } |
| String statusText = HttpStatus.getStatusText(method.getStatusCode()); |
| throw new Exception(TaskBundle.message("failure.http.error", statusCode, statusText)); |
| } |
| |
| private static boolean containsCookie(@NotNull HttpClient client, @NotNull String cookieName) { |
| for (Cookie cookie : client.getState().getCookies()) { |
| if (cookie.getName().equals(cookieName) && !cookie.isExpired()) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private void clearCookies() { |
| getHttpClient().getState().clearCookies(); |
| } |
| |
| // Made public for SOAP API compatibility |
| @Override |
| public HttpClient getHttpClient() { |
| return super.getHttpClient(); |
| } |
| |
| @Override |
| protected void configureHttpClient(HttpClient client) { |
| super.configureHttpClient(client); |
| client.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY); |
| } |
| |
| @Override |
| protected int getFeatures() { |
| int features = super.getFeatures(); |
| if (isRestApiSupported()) { |
| return features | TIME_MANAGEMENT | STATE_UPDATING; |
| } |
| else { |
| return features & ~NATIVE_SEARCH & ~STATE_UPDATING & ~TIME_MANAGEMENT; |
| } |
| } |
| |
| private boolean isRestApiSupported() { |
| return myApiVersion != null && myApiVersion.getType() != JiraRemoteApi.ApiType.LEGACY; |
| } |
| |
| public boolean isJqlSupported() { |
| return isRestApiSupported(); |
| } |
| |
| public String getSearchQuery() { |
| return mySearchQuery; |
| } |
| |
| @Override |
| public void setTaskState(@NotNull Task task, @NotNull TaskState state) throws Exception { |
| myApiVersion.setTaskState(task, state); |
| } |
| |
| public void setSearchQuery(String searchQuery) { |
| mySearchQuery = searchQuery; |
| } |
| |
| @Override |
| public void setUrl(String url) { |
| // reset remote API version, only if server URL was changed |
| if (!getUrl().equals(url)) { |
| myApiVersion = null; |
| super.setUrl(url); |
| } |
| } |
| |
| /** |
| * Used to preserve discovered API version for the next initialization. |
| * |
| * @return |
| */ |
| @SuppressWarnings("UnusedDeclaration") |
| @Nullable |
| public JiraRemoteApi.ApiType getApiType() { |
| return myApiVersion == null ? null : myApiVersion.getType(); |
| } |
| |
| @SuppressWarnings("UnusedDeclaration") |
| public void setApiType(@Nullable JiraRemoteApi.ApiType type) { |
| if (type != null) { |
| myApiVersion = type.createApi(this); |
| } |
| } |
| |
| @Nullable |
| public String getJiraVersion() { |
| return myJiraVersion; |
| } |
| |
| @SuppressWarnings("UnusedDeclaration") |
| public void setJiraVersion(@Nullable String jiraVersion) { |
| myJiraVersion = jiraVersion; |
| } |
| |
| public String getRestUrl(String... parts) { |
| return getUrl() + REST_API_PATH + "/" + StringUtil.join(parts, "/"); |
| } |
| } |