summaryrefslogtreecommitdiff
path: root/plugins/tasks/tasks-core/jira/src/com/intellij/tasks/jira/JiraRepository.java
blob: fa80fb1611885a5493201f45fdccee04819afae5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
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, "/");
  }
}