IDEA-157507 Use "deploymentType" field in serverInfo to detect instances of JIRA...
[idea/community.git] / plugins / tasks / tasks-core / jira / src / com / intellij / tasks / jira / JiraRepository.java
1 /*
2  * Copyright 2000-2014 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.intellij.tasks.jira;
17
18 import com.google.gson.Gson;
19 import com.google.gson.JsonElement;
20 import com.google.gson.JsonObject;
21 import com.intellij.openapi.diagnostic.Logger;
22 import com.intellij.openapi.util.Comparing;
23 import com.intellij.openapi.util.io.StreamUtil;
24 import com.intellij.openapi.util.text.StringUtil;
25 import com.intellij.openapi.vfs.CharsetToolkit;
26 import com.intellij.tasks.CustomTaskState;
27 import com.intellij.tasks.LocalTask;
28 import com.intellij.tasks.Task;
29 import com.intellij.tasks.TaskBundle;
30 import com.intellij.tasks.impl.BaseRepositoryImpl;
31 import com.intellij.tasks.impl.gson.TaskGsonUtil;
32 import com.intellij.tasks.jira.rest.JiraRestApi;
33 import com.intellij.tasks.jira.soap.JiraLegacyApi;
34 import com.intellij.util.ArrayUtil;
35 import com.intellij.util.containers.ContainerUtil;
36 import com.intellij.util.xmlb.annotations.Tag;
37 import org.apache.commons.httpclient.*;
38 import org.apache.commons.httpclient.cookie.CookiePolicy;
39 import org.apache.commons.httpclient.methods.GetMethod;
40 import org.apache.xmlrpc.CommonsXmlRpcTransport;
41 import org.apache.xmlrpc.XmlRpcClient;
42 import org.apache.xmlrpc.XmlRpcRequest;
43 import org.jetbrains.annotations.NotNull;
44 import org.jetbrains.annotations.Nullable;
45
46 import java.io.InputStream;
47 import java.net.MalformedURLException;
48 import java.net.URL;
49 import java.util.*;
50 import java.util.regex.Pattern;
51
52 /**
53  * @author Dmitry Avdeev
54  */
55 @SuppressWarnings("UseOfObsoleteCollectionType")
56 @Tag("JIRA")
57 public class JiraRepository extends BaseRepositoryImpl {
58
59   public static final Gson GSON = TaskGsonUtil.createDefaultBuilder().create();
60   private final static Logger LOG = Logger.getInstance(JiraRepository.class);
61   public static final String REST_API_PATH = "/rest/api/latest";
62
63   private static final boolean LEGACY_API_ONLY = Boolean.getBoolean("tasks.jira.legacy.api.only");
64   private static final boolean BASIC_AUTH_ONLY = Boolean.getBoolean("tasks.jira.basic.auth.only");
65   private static final boolean REDISCOVER_API = Boolean.getBoolean("tasks.jira.rediscover.api");
66
67   public static final Pattern JIRA_ID_PATTERN = Pattern.compile("\\p{javaUpperCase}+-\\d+");
68   public static final String AUTH_COOKIE_NAME = "JSESSIONID";
69
70   /**
71    * Default JQL query
72    */
73   private String mySearchQuery = TaskBundle.message("jira.default.query");
74
75   private JiraRemoteApi myApiVersion;
76   private String myJiraVersion;
77   private boolean myInCloud = false;
78
79   /**
80    * Serialization constructor
81    */
82   @SuppressWarnings({"UnusedDeclaration"})
83   public JiraRepository() {
84     setUseHttpAuthentication(true);
85   }
86
87   public JiraRepository(JiraRepositoryType type) {
88     super(type);
89     // Use Basic authentication at the beginning of new session and disable then if needed
90     setUseHttpAuthentication(true);
91   }
92
93   private JiraRepository(JiraRepository other) {
94     super(other);
95     mySearchQuery = other.mySearchQuery;
96     myJiraVersion = other.myJiraVersion;
97     myInCloud = other.myInCloud;
98     if (other.myApiVersion != null) {
99       myApiVersion = other.myApiVersion.getType().createApi(this);
100     }
101   }
102
103   @Override
104   public boolean equals(Object o) {
105     if (!super.equals(o)) return false;
106     if (!(o instanceof JiraRepository)) return false;
107
108     JiraRepository repository = (JiraRepository)o;
109
110     if (!Comparing.equal(mySearchQuery, repository.getSearchQuery())) return false;
111     if (!Comparing.equal(myJiraVersion, repository.getJiraVersion())) return false;
112     if (!Comparing.equal(myInCloud, repository.isInCloud())) return false;
113     return true;
114   }
115
116
117   @NotNull
118   public JiraRepository clone() {
119     return new JiraRepository(this);
120   }
121
122   public Task[] getIssues(@Nullable String query, int max, long since) throws Exception {
123     ensureApiVersionDiscovered();
124     String resultQuery = StringUtil.notNullize(query);
125     if (isJqlSupported()) {
126       if (StringUtil.isNotEmpty(mySearchQuery) && StringUtil.isNotEmpty(query)) {
127         resultQuery = String.format("summary ~ '%s' and ", query) + mySearchQuery;
128       }
129       else if (StringUtil.isNotEmpty(query)) {
130         resultQuery = String.format("summary ~ '%s'", query);
131       }
132       else {
133         resultQuery = mySearchQuery;
134       }
135     }
136     List<Task> tasksFound = myApiVersion.findTasks(resultQuery, max);
137     // JQL matching doesn't allow to do something like "summary ~ query or key = query"
138     // and it will return error immediately. So we have to search in two steps to provide
139     // behavior consistent with e.g. YouTrack.
140     // looks like issue ID
141     if (query != null && JIRA_ID_PATTERN.matcher(query.trim()).matches()) {
142       Task task = findTask(query);
143       if (task != null) {
144         tasksFound = ContainerUtil.append(tasksFound, task);
145       }
146     }
147     return ArrayUtil.toObjectArray(tasksFound, Task.class);
148   }
149
150   @Nullable
151   @Override
152   public Task findTask(@NotNull String id) throws Exception {
153     ensureApiVersionDiscovered();
154     return myApiVersion.findTask(id);
155   }
156
157   @Override
158   public void updateTimeSpent(@NotNull LocalTask task, @NotNull String timeSpent, @NotNull String comment) throws Exception {
159     myApiVersion.updateTimeSpend(task, timeSpent, comment);
160   }
161
162   @Nullable
163   @Override
164   public CancellableConnection createCancellableConnection() {
165     clearCookies();
166     // TODO cancellable connection for XML_RPC?
167     return new CancellableConnection() {
168       @Override
169       protected void doTest() throws Exception {
170         ensureApiVersionDiscovered();
171         myApiVersion.findTasks(mySearchQuery, 1);
172       }
173
174       @Override
175       public void cancel() {
176         // do nothing for now
177       }
178     };
179   }
180
181   @NotNull
182   public JiraRemoteApi discoverApiVersion() throws Exception {
183     if (LEGACY_API_ONLY) {
184       LOG.info("Intentionally using only legacy JIRA API");
185       return createLegacyApi();
186     }
187
188     String responseBody;
189     GetMethod method = new GetMethod(getRestUrl("serverInfo"));
190     try {
191       responseBody = executeMethod(method);
192     }
193     catch (Exception e) {
194       // probably JIRA version prior 4.2
195       // It's not safe to call HttpMethod.getStatusCode() directly, because it will throw NPE
196       // if response was not received (connection lost etc.) and hasBeenUsed()/isRequestSent() are
197       // not the way to check it safely.
198       StatusLine status = method.getStatusLine();
199       if (status != null && status.getStatusCode() == HttpStatus.SC_NOT_FOUND) {
200         return createLegacyApi();
201       }
202       else {
203         throw e;
204       }
205     }
206     JsonObject serverInfo = GSON.fromJson(responseBody, JsonObject.class);
207     // when JIRA 4.x support will be dropped 'versionNumber' array in response
208     // may be used instead version string parsing
209     myJiraVersion = serverInfo.get("version").getAsString();
210     myInCloud = isHostedInCloud(serverInfo);
211     LOG.info("JIRA version (from serverInfo): " + getPresentableVersion());
212     if (isInCloud()) {
213       LOG.info("Connecting to JIRA Cloud. Cookie authentication is enabled unless 'tasks.jira.basic.auth.only' VM flag is used.");
214     }
215     JiraRestApi restApi = JiraRestApi.fromJiraVersion(myJiraVersion, this);
216     if (restApi == null) {
217       throw new Exception(TaskBundle.message("jira.failure.no.REST"));
218     }
219     return restApi;
220   }
221
222   private static boolean isHostedInCloud(@NotNull JsonObject serverInfo) {
223     final JsonElement deploymentType = serverInfo.get("deploymentType");
224     if (deploymentType != null) {
225       return deploymentType.getAsString().equals("Cloud");  
226     }
227     // Legacy heuristics 
228     final boolean atlassianSubDomain = hostEndsWith(serverInfo.get("baseUrl").getAsString(), ".atlassian.net");
229     if (atlassianSubDomain) {
230       return true;
231     }
232     // JIRA OnDemand versions contained "OD" abbreviation
233     return serverInfo.get("version").getAsString().contains("OD") ;
234   }
235
236   private static boolean hostEndsWith(@NotNull String url, @NotNull String suffix) {
237     try {
238       final URL parsed = new URL(url);
239       return parsed.getHost().endsWith(suffix);
240     }
241     catch (MalformedURLException ignored) {
242     }
243     return false;
244   }
245
246   private JiraLegacyApi createLegacyApi() {
247     try {
248       XmlRpcClient client = new XmlRpcClient(getUrl());
249       Vector<String> parameters = new Vector<>(Collections.singletonList(""));
250       XmlRpcRequest request = new XmlRpcRequest("jira1.getServerInfo", parameters);
251       @SuppressWarnings("unchecked") Hashtable<String, Object> response =
252         (Hashtable<String, Object>)client.execute(request, new CommonsXmlRpcTransport(new URL(getUrl()), getHttpClient()));
253       if (response != null) {
254         myJiraVersion = (String)response.get("version");
255       }
256     }
257     catch (Exception e) {
258       LOG.error("Cannot find out JIRA version via XML-RPC", e);
259     }
260     return new JiraLegacyApi(this);
261   }
262
263   private void ensureApiVersionDiscovered() throws Exception {
264     if (myApiVersion == null || LEGACY_API_ONLY || REDISCOVER_API) {
265       myApiVersion = discoverApiVersion();
266     }
267   }
268
269   @NotNull
270   public String executeMethod(@NotNull HttpMethod method) throws Exception {
271     LOG.debug("URI: " + method.getURI());
272
273     HttpClient client = getHttpClient();
274     // Fix for https://jetbrains.zendesk.com/agent/#/tickets/24566
275     // See https://confluence.atlassian.com/display/ONDEMANDKB/Getting+randomly+logged+out+of+OnDemand for details
276     // IDEA-128824, IDEA-128706 Use cookie authentication only for JIRA on-Demand
277     // TODO Make JiraVersion more suitable for such checks
278     if (BASIC_AUTH_ONLY || !isInCloud()) {
279       // to override persisted settings
280       setUseHttpAuthentication(true);
281     }
282     else {
283       boolean enableBasicAuthentication = !(isRestApiSupported() && containsCookie(client, AUTH_COOKIE_NAME));
284       if (enableBasicAuthentication != isUseHttpAuthentication()) {
285         LOG.info("Basic authentication for subsequent requests was " + (enableBasicAuthentication ? "enabled" : "disabled"));
286       }
287       setUseHttpAuthentication(enableBasicAuthentication);
288     }
289
290     int statusCode = client.executeMethod(method);
291     LOG.debug("Status code: " + statusCode);
292     // may be null if 204 No Content received
293     final InputStream stream = method.getResponseBodyAsStream();
294     String entityContent = stream == null ? "" : StreamUtil.readText(stream, CharsetToolkit.UTF8);
295     //TaskUtil.prettyFormatJsonToLog(LOG, entityContent);
296     // besides SC_OK, can also be SC_NO_CONTENT in issue transition requests
297     // see: JiraRestApi#setTaskStatus
298     //if (statusCode == HttpStatus.SC_OK || statusCode == HttpStatus.SC_NO_CONTENT) {
299     if (statusCode >= 200 && statusCode < 300) {
300       return entityContent;
301     }
302     clearCookies();
303     if (method.getResponseHeader("Content-Type") != null) {
304       Header header = method.getResponseHeader("Content-Type");
305       if (header.getValue().startsWith("application/json")) {
306         JsonObject object = GSON.fromJson(entityContent, JsonObject.class);
307         if (object.has("errorMessages")) {
308           String reason = StringUtil.join(object.getAsJsonArray("errorMessages"), " ");
309           // something meaningful to user, e.g. invalid field name in JQL query
310           LOG.warn(reason);
311           throw new Exception(TaskBundle.message("failure.server.message", reason));
312         }
313       }
314     }
315     if (method.getResponseHeader("X-Authentication-Denied-Reason") != null) {
316       Header header = method.getResponseHeader("X-Authentication-Denied-Reason");
317       // only in JIRA >= 5.x.x
318       if (header.getValue().startsWith("CAPTCHA_CHALLENGE")) {
319         throw new Exception(TaskBundle.message("jira.failure.captcha"));
320       }
321     }
322     if (statusCode == HttpStatus.SC_UNAUTHORIZED) {
323       throw new Exception(TaskBundle.message("failure.login"));
324     }
325     String statusText = HttpStatus.getStatusText(method.getStatusCode());
326     throw new Exception(TaskBundle.message("failure.http.error", statusCode, statusText));
327   }
328
329   public boolean isInCloud() {
330     return myInCloud;
331   }
332
333   public void setInCloud(boolean inCloud) {
334     myInCloud = inCloud;
335   }
336
337   @NotNull
338   String getPresentableVersion() {
339     return StringUtil.notNullize(myJiraVersion, "unknown") + (myInCloud ? " (Cloud)" : "");
340   }
341   
342   private static boolean containsCookie(@NotNull HttpClient client, @NotNull String cookieName) {
343     for (Cookie cookie : client.getState().getCookies()) {
344       if (cookie.getName().equals(cookieName) && !cookie.isExpired()) {
345         return true;
346       }
347     }
348     return false;
349   }
350
351   private void clearCookies() {
352     getHttpClient().getState().clearCookies();
353   }
354
355   // Made public for SOAP API compatibility
356   @Override
357   public HttpClient getHttpClient() {
358     return super.getHttpClient();
359   }
360
361   @Override
362   protected void configureHttpClient(HttpClient client) {
363     super.configureHttpClient(client);
364     client.getParams().setCookiePolicy(CookiePolicy.BROWSER_COMPATIBILITY);
365   }
366
367   @Override
368   protected int getFeatures() {
369     int features = super.getFeatures();
370     if (isRestApiSupported()) {
371       return features | TIME_MANAGEMENT | STATE_UPDATING;
372     }
373     else {
374       return features & ~NATIVE_SEARCH & ~STATE_UPDATING & ~TIME_MANAGEMENT;
375     }
376   }
377
378   private boolean isRestApiSupported() {
379     return myApiVersion != null && myApiVersion.getType() != JiraRemoteApi.ApiType.LEGACY;
380   }
381
382   public boolean isJqlSupported() {
383     return isRestApiSupported();
384   }
385
386   public String getSearchQuery() {
387     return mySearchQuery;
388   }
389
390   @Override
391   public void setTaskState(@NotNull Task task, @NotNull CustomTaskState state) throws Exception {
392     myApiVersion.setTaskState(task, state);
393   }
394
395   @NotNull
396   @Override
397   public Set<CustomTaskState> getAvailableTaskStates(@NotNull Task task) throws Exception {
398     return myApiVersion.getAvailableTaskStates(task);
399   }
400
401   public void setSearchQuery(String searchQuery) {
402     mySearchQuery = searchQuery;
403   }
404
405   @Override
406   public void setUrl(String url) {
407     // Compare only normalized URLs
408     final String oldUrl = getUrl();
409     super.setUrl(url);
410     // reset remote API version, only if server URL was changed
411     if (!getUrl().equals(oldUrl)) {
412       myApiVersion = null;
413       myInCloud = false;
414     }
415   }
416
417   /**
418    * Used to preserve discovered API version for the next initialization.
419    */
420   @SuppressWarnings("UnusedDeclaration")
421   @Nullable
422   public JiraRemoteApi.ApiType getApiType() {
423     return myApiVersion == null ? null : myApiVersion.getType();
424   }
425
426   @SuppressWarnings("UnusedDeclaration")
427   public void setApiType(@Nullable JiraRemoteApi.ApiType type) {
428     if (type != null) {
429       myApiVersion = type.createApi(this);
430     }
431   }
432
433   @Nullable
434   public String getJiraVersion() {
435     return myJiraVersion;
436   }
437
438   @SuppressWarnings("UnusedDeclaration")
439   public void setJiraVersion(@Nullable String jiraVersion) {
440     myJiraVersion = jiraVersion;
441   }
442
443   public String getRestUrl(String... parts) {
444     return getUrl() + REST_API_PATH + "/" + StringUtil.join(parts, "/");
445   }
446 }