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