1#!/usr/bin/python
2# -*- coding: utf-8 -*-
3from __future__ import unicode_literals
4from __future__ import print_function
5
6from requests.auth import AuthBase
7
8"""
9This module implements a friendly (well, friendlier) interface between the raw JSON
10responses from JIRA and the Resource/dict abstractions provided by this library. Users
11will construct a JIRA object as described below. Full API documentation can be found
12at: https://jira-python.readthedocs.org/en/latest/
13"""
14
15from functools import wraps
16
17import imghdr
18import mimetypes
19
20import collections
21import copy
22import json
23import logging
24import os
25import re
26import tempfile
27try:  # Python 2.7+
28    from logging import NullHandler
29except ImportError:
30    class NullHandler(logging.Handler):
31
32        def emit(self, record):
33            pass
34import calendar
35import datetime
36import hashlib
37from numbers import Number
38import requests
39import sys
40import time
41import warnings
42
43from requests.utils import get_netrc_auth
44from six import iteritems
45from six.moves.urllib.parse import urlparse
46
47# GreenHopper specific resources
48from jira.exceptions import JIRAError
49from jira.resilientsession import raise_on_error
50from jira.resilientsession import ResilientSession
51# JIRA specific resources
52from jira.resources import Attachment
53from jira.resources import Board
54from jira.resources import Comment
55from jira.resources import Component
56from jira.resources import Customer
57from jira.resources import CustomFieldOption
58from jira.resources import Dashboard
59from jira.resources import Filter
60from jira.resources import GreenHopperResource
61from jira.resources import Issue
62from jira.resources import IssueLink
63from jira.resources import IssueLinkType
64from jira.resources import IssueType
65from jira.resources import Priority
66from jira.resources import Project
67from jira.resources import RemoteLink
68from jira.resources import RequestType
69from jira.resources import Resolution
70from jira.resources import Resource
71from jira.resources import Role
72from jira.resources import SecurityLevel
73from jira.resources import ServiceDesk
74from jira.resources import Sprint
75from jira.resources import Status
76from jira.resources import User
77from jira.resources import Version
78from jira.resources import Votes
79from jira.resources import Watchers
80from jira.resources import Worklog
81
82from jira import __version__
83from jira.utils import CaseInsensitiveDict
84from jira.utils import json_loads
85from jira.utils import threaded_requests
86from pkg_resources import parse_version
87
88from collections import OrderedDict
89
90from six import integer_types
91from six import string_types
92
93# six.moves does not play well with pyinstaller, see https://github.com/pycontribs/jira/issues/38
94try:
95    # noinspection PyUnresolvedReferences
96    from requests_toolbelt import MultipartEncoder
97except ImportError:
98    pass
99
100try:
101    from requests_jwt import JWTAuth
102except ImportError:
103    pass
104
105# warnings.simplefilter('default')
106
107# encoding = sys.getdefaultencoding()
108# if encoding != 'UTF8':
109#    warnings.warning("Python default encoding is '%s' instead of 'UTF8' " \
110#    "which means that there is a big change of having problems. " \
111#    "Possible workaround http://stackoverflow.com/a/17628350/99834" % encoding)
112
113logging.getLogger('jira').addHandler(NullHandler())
114
115
116def translate_resource_args(func):
117    """Decorator that converts Issue and Project resources to their keys when used as arguments."""
118    @wraps(func)
119    def wrapper(*args, **kwargs):
120        arg_list = []
121        for arg in args:
122            if isinstance(arg, (Issue, Project)):
123                arg_list.append(arg.key)
124            else:
125                arg_list.append(arg)
126        result = func(*arg_list, **kwargs)
127        return result
128
129    return wrapper
130
131
132def _get_template_list(data):
133    template_list = []
134    if 'projectTemplates' in data:
135        template_list = data['projectTemplates']
136    elif 'projectTemplatesGroupedByType' in data:
137        for group in data['projectTemplatesGroupedByType']:
138            template_list.extend(group['projectTemplates'])
139    return template_list
140
141
142def _field_worker(fields=None, **fieldargs):
143    if fields is not None:
144        return {'fields': fields}
145    return {'fields': fieldargs}
146
147
148class ResultList(list):
149
150    def __init__(self, iterable=None, _startAt=0, _maxResults=0, _total=0, _isLast=None):
151        if iterable is not None:
152            list.__init__(self, iterable)
153        else:
154            list.__init__(self)
155
156        self.startAt = _startAt
157        self.maxResults = _maxResults
158        # Optional parameters:
159        self.isLast = _isLast
160        self.total = _total
161
162        self.iterable = iterable or []
163        self.current = self.startAt
164
165    def __next__(self):
166        self.current += 1
167        if self.current > self.total:
168            raise StopIteration
169        else:
170            return self.iterable[self.current - 1]
171    # Python 2 and 3 compat
172    next = __next__
173
174
175class QshGenerator(object):
176
177    def __init__(self, context_path):
178        self.context_path = context_path
179
180    def __call__(self, req):
181        parse_result = urlparse(req.url)
182
183        path = parse_result.path[len(self.context_path):] if len(self.context_path) > 1 else parse_result.path
184        # Per Atlassian docs, use %20 for whitespace when generating qsh for URL
185        # https://developer.atlassian.com/cloud/jira/platform/understanding-jwt/#qsh
186        query = '&'.join(sorted(parse_result.query.split("&"))).replace('+', '%20')
187        qsh = '%(method)s&%(path)s&%(query)s' % {'method': req.method.upper(), 'path': path, 'query': query}
188
189        return hashlib.sha256(qsh.encode('utf-8')).hexdigest()
190
191
192class JiraCookieAuth(AuthBase):
193    """Jira Cookie Authentication
194
195    Allows using cookie authentication as described by
196    https://developer.atlassian.com/jiradev/jira-apis/jira-rest-apis/jira-rest-api-tutorials/jira-rest-api-example-cookie-based-authentication
197
198    """
199
200    def __init__(self, session, _get_session, auth):
201        self._session = session
202        self._get_session = _get_session
203        self.__auth = auth
204
205    def handle_401(self, response, **kwargs):
206        if response.status_code != 401:
207            return response
208        self.init_session()
209        response = self.process_original_request(response.request.copy())
210        return response
211
212    def process_original_request(self, original_request):
213        self.update_cookies(original_request)
214        return self.send_request(original_request)
215
216    def update_cookies(self, original_request):
217        # Cookie header needs first to be deleted for the header to be updated using
218        # the prepare_cookies method. See request.PrepareRequest.prepare_cookies
219        if 'Cookie' in original_request.headers:
220            del original_request.headers['Cookie']
221        original_request.prepare_cookies(self.cookies)
222
223    def init_session(self):
224        self.start_session()
225
226    def __call__(self, request):
227        request.register_hook('response', self.handle_401)
228        return request
229
230    def send_request(self, request):
231        return self._session.send(request)
232
233    @property
234    def cookies(self):
235        return self._session.cookies
236
237    def start_session(self):
238        self._get_session(self.__auth)
239
240
241class JIRA(object):
242    """User interface to JIRA.
243
244    Clients interact with JIRA by constructing an instance of this object and calling its methods. For addressable
245    resources in JIRA -- those with "self" links -- an appropriate subclass of :py:class:`Resource` will be returned
246    with customized ``update()`` and ``delete()`` methods, along with attribute access to fields. This means that calls
247    of the form ``issue.fields.summary`` will be resolved into the proper lookups to return the JSON value at that
248    mapping. Methods that do not return resources will return a dict constructed from the JSON response or a scalar
249    value; see each method's documentation for details on what that method returns.
250
251    Without any arguments, this client will connect anonymously to the JIRA instance
252    started by the Atlassian Plugin SDK from one of the 'atlas-run', ``atlas-debug``,
253    or ``atlas-run-standalone`` commands. By default, this instance runs at
254    ``http://localhost:2990/jira``. The ``options`` argument can be used to set the JIRA instance to use.
255
256    Authentication is handled with the ``basic_auth`` argument. If authentication is supplied (and is
257    accepted by JIRA), the client will remember it for subsequent requests.
258
259    For quick command line access to a server, see the ``jirashell`` script included with this distribution.
260
261    The easiest way to instantiate is using ``j = JIRA("https://jira.atlassian.com")``
262
263    :param options: Specify the server and properties this client will use. Use a dict with any
264        of the following properties:
265
266        * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``.
267        * rest_path -- the root REST path to use. Defaults to ``api``, where the JIRA REST resources live.
268        * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``.
269        * agile_rest_path - the REST path to use for JIRA Agile requests. Defaults to ``greenhopper`` (old, private
270                API). Check `GreenHopperResource` for other supported values.
271        * verify -- Verify SSL certs. Defaults to ``True``.
272        * client_cert -- a tuple of (cert,key) for the requests library for client side SSL
273        * check_update -- Check whether using the newest python-jira library version.
274        * cookies -- A dict of custom cookies that are sent in all requests to the server.
275
276    :param basic_auth: A tuple of username and password to use when establishing a session via HTTP BASIC
277        authentication.
278    :param oauth: A dict of properties for OAuth authentication. The following properties are required:
279
280        * access_token -- OAuth access token for the user
281        * access_token_secret -- OAuth access token secret to sign with the key
282        * consumer_key -- key of the OAuth application link defined in JIRA
283        * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to
284          JIRA in the OAuth application link)
285
286    :param kerberos: If true it will enable Kerberos authentication.
287    :param kerberos_options: A dict of properties for Kerberos authentication. The following properties are possible:
288
289        * mutual_authentication -- string DISABLED or OPTIONAL.
290
291        Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}``
292
293    :param jwt: A dict of properties for JWT authentication supported by Atlassian Connect. The following
294        properties are required:
295
296        * secret -- shared secret as delivered during 'installed' lifecycle event
297          (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details)
298        * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss'
299
300        Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}``
301
302    :param validate: If true it will validate your credentials first. Remember that if you are accessing JIRA
303        as anonymous it will fail to instantiate.
304    :param get_server_info: If true it will fetch server version info first to determine if some API calls
305        are available.
306    :param async_: To enable asynchronous requests for those actions where we implemented it, like issue update() or delete().
307    :param async_workers: Set the number of worker threads for async operations.
308    :param timeout: Set a read/connect timeout for the underlying calls to JIRA (default: None)
309        Obviously this means that you cannot rely on the return code when this is enabled.
310    """
311
312    DEFAULT_OPTIONS = {
313        "server": "http://localhost:2990/jira",
314        "auth_url": '/rest/auth/1/session',
315        "context_path": "/",
316        "rest_path": "api",
317        "rest_api_version": "2",
318        "agile_rest_path": GreenHopperResource.GREENHOPPER_REST_PATH,
319        "agile_rest_api_version": "1.0",
320        "verify": True,
321        "resilient": True,
322        "async": False,
323        "async_workers": 5,
324        "client_cert": None,
325        "check_update": False,
326        "headers": {
327            'Cache-Control': 'no-cache',
328            # 'Accept': 'application/json;charset=UTF-8',  # default for REST
329            'Content-Type': 'application/json',  # ;charset=UTF-8',
330            # 'Accept': 'application/json',  # default for REST
331            # 'Pragma': 'no-cache',
332            # 'Expires': 'Thu, 01 Jan 1970 00:00:00 GMT'
333            'X-Atlassian-Token': 'no-check'}}
334
335    checked_version = False
336
337    # TODO(ssbarnea): remove these two variables and use the ones defined in resources
338    JIRA_BASE_URL = Resource.JIRA_BASE_URL
339    AGILE_BASE_URL = GreenHopperResource.AGILE_BASE_URL
340
341    def __init__(self, server=None, options=None, basic_auth=None, oauth=None, jwt=None, kerberos=False, kerberos_options=None,
342                 validate=False, get_server_info=True, async_=False, async_workers=5, logging=True, max_retries=3, proxies=None,
343                 timeout=None, auth=None):
344        """Construct a JIRA client instance.
345
346        Without any arguments, this client will connect anonymously to the JIRA instance
347        started by the Atlassian Plugin SDK from one of the 'atlas-run', ``atlas-debug``,
348        or ``atlas-run-standalone`` commands. By default, this instance runs at
349        ``http://localhost:2990/jira``. The ``options`` argument can be used to set the JIRA instance to use.
350
351        Authentication is handled with the ``basic_auth`` argument. If authentication is supplied (and is
352        accepted by JIRA), the client will remember it for subsequent requests.
353
354        For quick command line access to a server, see the ``jirashell`` script included with this distribution.
355
356        The easiest way to instantiate is using j = JIRA("https://jira.atlasian.com")
357
358        :param options: Specify the server and properties this client will use. Use a dict with any
359            of the following properties:
360            * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``.
361            * rest_path -- the root REST path to use. Defaults to ``api``, where the JIRA REST resources live.
362            * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``.
363            * agile_rest_path - the REST path to use for JIRA Agile requests. Defaults to ``greenhopper`` (old, private
364               API). Check `GreenHopperResource` for other supported values.
365            * verify -- Verify SSL certs. Defaults to ``True``.
366            * client_cert -- a tuple of (cert,key) for the requests library for client side SSL
367            * check_update -- Check whether using the newest python-jira library version.
368        :param basic_auth: A tuple of username and password to use when establishing a session via HTTP BASIC
369        authentication.
370        :param oauth: A dict of properties for OAuth authentication. The following properties are required:
371            * access_token -- OAuth access token for the user
372            * access_token_secret -- OAuth access token secret to sign with the key
373            * consumer_key -- key of the OAuth application link defined in JIRA
374            * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to
375            JIRA in the OAuth application link)
376        :param kerberos: If true it will enable Kerberos authentication.
377        :param kerberos_options: A dict of properties for Kerberos authentication. The following properties are possible:
378            * mutual_authentication -- string DISABLED or OPTIONAL.
379            Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}``
380        :param jwt: A dict of properties for JWT authentication supported by Atlassian Connect. The following
381            properties are required:
382            * secret -- shared secret as delivered during 'installed' lifecycle event
383            (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details)
384            * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss'
385            Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}``
386        :param validate: If true it will validate your credentials first. Remember that if you are accessing JIRA
387            as anonymous it will fail to instantiate.
388        :param get_server_info: If true it will fetch server version info first to determine if some API calls
389            are available.
390        :param async_: To enable async requests for those actions where we implemented it, like issue update() or delete().
391        :param async_workers: Set the number of worker threads for async operations.
392        :param timeout: Set a read/connect timeout for the underlying calls to JIRA (default: None)
393        Obviously this means that you cannot rely on the return code when this is enabled.
394        :param auth: Set a cookie auth token if this is required.
395        """
396        # force a copy of the tuple to be used in __del__() because
397        # sys.version_info could have already been deleted in __del__()
398        self.sys_version_info = tuple([i for i in sys.version_info])
399
400        if options is None:
401            options = {}
402            if server and hasattr(server, 'keys'):
403                warnings.warn(
404                    "Old API usage, use JIRA(url) or JIRA(options={'server': url}, when using dictionary always use named parameters.",
405                    DeprecationWarning)
406                options = server
407                server = None
408
409        if server:
410            options['server'] = server
411        if async_:
412            options['async'] = async_
413            options['async_workers'] = async_workers
414
415        self.logging = logging
416
417        self._options = copy.copy(JIRA.DEFAULT_OPTIONS)
418
419        self._options.update(options)
420
421        self._rank = None
422
423        # Rip off trailing slash since all urls depend on that
424        if self._options['server'].endswith('/'):
425            self._options['server'] = self._options['server'][:-1]
426
427        context_path = urlparse(self._options['server']).path
428        if len(context_path) > 0:
429            self._options['context_path'] = context_path
430
431        self._try_magic()
432
433        if oauth:
434            self._create_oauth_session(oauth, timeout)
435        elif basic_auth:
436            self._create_http_basic_session(*basic_auth, timeout=timeout)
437            self._session.headers.update(self._options['headers'])
438        elif jwt:
439            self._create_jwt_session(jwt, timeout)
440        elif kerberos:
441            self._create_kerberos_session(timeout, kerberos_options=kerberos_options)
442        elif auth:
443            self._create_cookie_auth(auth, timeout)
444            validate = True  # always log in for cookie based auth, as we need a first request to be logged in
445        else:
446            verify = self._options['verify']
447            self._session = ResilientSession(timeout=timeout)
448            self._session.verify = verify
449        self._session.headers.update(self._options['headers'])
450
451        if 'cookies' in self._options:
452            self._session.cookies.update(self._options['cookies'])
453
454        self._session.max_retries = max_retries
455
456        if proxies:
457            self._session.proxies = proxies
458
459        if validate:
460            # This will raise an Exception if you are not allowed to login.
461            # It's better to fail faster than later.
462            user = self.session(auth)
463            if user.raw is None:
464                auth_method = (
465                    oauth or basic_auth or jwt or kerberos or auth or "anonymous"
466                )
467                raise JIRAError("Can not log in with %s" % str(auth_method))
468
469        self.deploymentType = None
470        if get_server_info:
471            # We need version in order to know what API calls are available or not
472            si = self.server_info()
473            try:
474                self._version = tuple(si['versionNumbers'])
475            except Exception as e:
476                logging.error("invalid server_info: %s", si)
477                raise e
478            self.deploymentType = si.get('deploymentType')
479        else:
480            self._version = (0, 0, 0)
481
482        if self._options['check_update'] and not JIRA.checked_version:
483            self._check_update_()
484            JIRA.checked_version = True
485
486        self._fields = {}
487        for f in self.fields():
488            if 'clauseNames' in f:
489                for name in f['clauseNames']:
490                    self._fields[name] = f['id']
491
492    def _create_cookie_auth(self, auth, timeout):
493        self._session = ResilientSession(timeout=timeout)
494        self._session.auth = JiraCookieAuth(self._session, self.session, auth)
495        self._session.verify = self._options['verify']
496        self._session.cert = self._options['client_cert']
497
498    def _check_update_(self):
499        """Check if the current version of the library is outdated."""
500        try:
501            data = requests.get("https://pypi.python.org/pypi/jira/json", timeout=2.001).json()
502
503            released_version = data['info']['version']
504            if parse_version(released_version) > parse_version(__version__):
505                warnings.warn(
506                    "You are running an outdated version of JIRA Python %s. Current version is %s. Do not file any bugs against older versions." % (
507                        __version__, released_version))
508        except requests.RequestException:
509            pass
510        except Exception as e:
511            logging.warning(e)
512
513    def __del__(self):
514        """Destructor for JIRA instance."""
515        self.close()
516
517    def close(self):
518        session = getattr(self, "_session", None)
519        if session is not None:
520            self._session = None
521            if self.sys_version_info < (3, 4, 0):  # workaround for https://github.com/kennethreitz/requests/issues/2303
522                try:
523                    session.close()
524                except TypeError:
525                    # TypeError: "'NoneType' object is not callable"
526                    # Could still happen here because other references are also
527                    # in the process to be torn down, see warning section in
528                    # https://docs.python.org/2/reference/datamodel.html#object.__del__
529                    pass
530
531    def _check_for_html_error(self, content):
532        # JIRA has the bad habit of returning errors in pages with 200 and
533        # embedding the error in a huge webpage.
534        if '<!-- SecurityTokenMissing -->' in content:
535            logging.warning("Got SecurityTokenMissing")
536            raise JIRAError("SecurityTokenMissing: %s" % content)
537            return False
538        return True
539
540    def _get_sprint_field_id(self):
541        sprint_field_name = "Sprint"
542        sprint_field_id = [f['schema']['customId'] for f in self.fields()
543                           if f['name'] == sprint_field_name][0]
544        return sprint_field_id
545
546    def _fetch_pages(self, item_type, items_key, request_path, startAt=0, maxResults=50, params=None, base=JIRA_BASE_URL):
547        """Fetch pages.
548
549        :param item_type: Type of single item. ResultList of such items will be returned.
550        :param items_key: Path to the items in JSON returned from server.
551                Set it to None, if response is an array, and not a JSON object.
552        :param request_path: path in request URL
553        :param startAt: index of the first record to be fetched
554        :param maxResults: Maximum number of items to return.
555                If maxResults evaluates as False, it will try to get all items in batches.
556        :param params: Params to be used in all requests. Should not contain startAt and maxResults,
557                        as they will be added for each request created from this function.
558        :param base: base URL
559        :return: ResultList
560        """
561        async_class = None
562        if self._options['async']:
563            try:
564                from requests_futures.sessions import FuturesSession
565                async_class = FuturesSession
566            except ImportError:
567                pass
568            async_workers = self._options['async_workers']
569        page_params = params.copy() if params else {}
570        if startAt:
571            page_params['startAt'] = startAt
572        if maxResults:
573            page_params['maxResults'] = maxResults
574
575        resource = self._get_json(request_path, params=page_params, base=base)
576        next_items_page = self._get_items_from_page(item_type, items_key,
577                                                    resource)
578        items = next_items_page
579
580        if True:  # isinstance(resource, dict):
581
582            if isinstance(resource, dict):
583                total = resource.get('total')
584                # 'isLast' is the optional key added to responses in JIRA Agile 6.7.6. So far not used in basic JIRA API.
585                is_last = resource.get('isLast', False)
586                start_at_from_response = resource.get('startAt', 0)
587                max_results_from_response = resource.get('maxResults', 1)
588            else:
589                # if is a list
590                total = 1
591                is_last = True
592                start_at_from_response = 0
593                max_results_from_response = 1
594
595            # If maxResults evaluates as False, get all items in batches
596            if not maxResults:
597                page_size = max_results_from_response or len(items)
598                page_start = (startAt or start_at_from_response or 0) + page_size
599                if async_class is not None and not is_last and (
600                        total is not None and len(items) < total):
601                    async_fetches = []
602                    future_session = async_class(session=self._session, max_workers=async_workers)
603                    for start_index in range(page_start, total, page_size):
604                        page_params = params.copy()
605                        page_params['startAt'] = start_index
606                        page_params['maxResults'] = page_size
607                        url = self._get_url(request_path)
608                        r = future_session.get(url, params=page_params)
609                        async_fetches.append(r)
610                    for future in async_fetches:
611                        response = future.result()
612                        resource = json_loads(response)
613                        if resource:
614                            next_items_page = self._get_items_from_page(
615                                item_type, items_key, resource)
616                            items.extend(next_items_page)
617                while async_class is None and not is_last and (
618                    total is None or page_start < total) and len(
619                        next_items_page) == page_size:
620                    page_params['startAt'] = page_start
621                    page_params['maxResults'] = page_size
622                    resource = self._get_json(request_path, params=page_params, base=base)
623                    if resource:
624                        next_items_page = self._get_items_from_page(
625                            item_type, items_key, resource)
626                        items.extend(next_items_page)
627                        page_start += page_size
628                    else:
629                        # if resource is an empty dictionary we assume no-results
630                        break
631
632            return ResultList(items, start_at_from_response, max_results_from_response, total, is_last)
633        else:
634            # it seams that search_users can return a list() containing a single user!
635            return ResultList([item_type(self._options, self._session, resource)], 0, 1, 1, True)
636
637    def _get_items_from_page(self, item_type, items_key, resource):
638        try:
639            return [item_type(self._options, self._session, raw_issue_json) for raw_issue_json in
640                    (resource[items_key] if items_key else resource)]
641        except KeyError as e:
642            # improving the error text so we know why it happened
643            raise KeyError(str(e) + " : " + json.dumps(resource))
644
645    # Information about this client
646
647    def client_info(self):
648        """Get the server this client is connected to."""
649        return self._options['server']
650
651    # Universal resource loading
652
653    def find(self, resource_format, ids=None):
654        """Find Resource object for any addressable resource on the server.
655
656        This method is a universal resource locator for any REST-ful resource in JIRA. The
657        argument ``resource_format`` is a string of the form ``resource``, ``resource/{0}``,
658        ``resource/{0}/sub``, ``resource/{0}/sub/{1}``, etc. The format placeholders will be
659        populated from the ``ids`` argument if present. The existing authentication session
660        will be used.
661
662        The return value is an untyped Resource object, which will not support specialized
663        :py:meth:`.Resource.update` or :py:meth:`.Resource.delete` behavior. Moreover, it will
664        not know to return an issue Resource if the client uses the resource issue path. For this
665        reason, it is intended to support resources that are not included in the standard
666        Atlassian REST API.
667
668        :param resource_format: the subpath to the resource string
669        :param ids: values to substitute in the ``resource_format`` string
670        :type ids: tuple or None
671        """
672        resource = Resource(resource_format, self._options, self._session)
673        resource.find(ids)
674        return resource
675
676    def async_do(self, size=10):
677        """Execute all asynchronous jobs and wait for them to finish. By default it will run on 10 threads.
678
679        :param size: number of threads to run on.
680        """
681        if hasattr(self._session, '_async_jobs'):
682            logging.info("Executing asynchronous %s jobs found in queue by using %s threads..." % (
683                len(self._session._async_jobs), size))
684            threaded_requests.map(self._session._async_jobs, size=size)
685
686            # Application properties
687
688    # non-resource
689    def application_properties(self, key=None):
690        """Return the mutable server application properties.
691
692        :param key: the single property to return a value for
693        """
694        params = {}
695        if key is not None:
696            params['key'] = key
697        return self._get_json('application-properties', params=params)
698
699    def set_application_property(self, key, value):
700        """Set the application property.
701
702        :param key: key of the property to set
703        :param value: value to assign to the property
704        """
705        url = self._options['server'] + \
706            '/rest/api/latest/application-properties/' + key
707        payload = {
708            'id': key,
709            'value': value}
710        return self._session.put(
711            url, data=json.dumps(payload))
712
713    def applicationlinks(self, cached=True):
714        """List of application links.
715
716        :return: json
717        """
718        # if cached, return the last result
719        if cached and hasattr(self, '_applicationlinks'):
720            return self._applicationlinks
721
722        # url = self._options['server'] + '/rest/applinks/latest/applicationlink'
723        url = self._options['server'] + \
724            '/rest/applinks/latest/listApplicationlinks'
725
726        r = self._session.get(url)
727
728        o = json_loads(r)
729        if 'list' in o:
730            self._applicationlinks = o['list']
731        else:
732            self._applicationlinks = []
733        return self._applicationlinks
734
735    # Attachments
736    def attachment(self, id):
737        """Get an attachment Resource from the server for the specified ID."""
738        return self._find_for_resource(Attachment, id)
739
740    # non-resource
741    def attachment_meta(self):
742        """Get the attachment metadata."""
743        return self._get_json('attachment/meta')
744
745    @translate_resource_args
746    def add_attachment(self, issue, attachment, filename=None):
747        """Attach an attachment to an issue and returns a Resource for it.
748
749        The client will *not* attempt to open or validate the attachment; it expects a file-like object to be ready
750        for its use. The user is still responsible for tidying up (e.g., closing the file, killing the socket, etc.)
751
752        :param issue: the issue to attach the attachment to
753        :param attachment: file-like object to attach to the issue, also works if it is a string with the filename.
754        :param filename: optional name for the attached file. If omitted, the file object's ``name`` attribute
755            is used. If you acquired the file-like object by any other method than ``open()``, make sure
756            that a name is specified in one way or the other.
757        :rtype: an Attachment Resource
758        """
759        if isinstance(attachment, string_types):
760            attachment = open(attachment, "rb")
761        if hasattr(attachment, 'read') and hasattr(attachment, 'mode') and attachment.mode != 'rb':
762            logging.warning(
763                "%s was not opened in 'rb' mode, attaching file may fail." % attachment.name)
764
765        url = self._get_url('issue/' + str(issue) + '/attachments')
766
767        fname = filename
768        if not fname:
769            fname = os.path.basename(attachment.name)
770
771        if 'MultipartEncoder' not in globals():
772            method = 'old'
773            r = self._session.post(
774                url,
775                files={
776                    'file': (fname, attachment, 'application/octet-stream')},
777                headers=CaseInsensitiveDict({'content-type': None, 'X-Atlassian-Token': 'nocheck'}))
778        else:
779            method = 'MultipartEncoder'
780
781            def file_stream():
782                return MultipartEncoder(
783                    fields={
784                        'file': (fname, attachment, 'application/octet-stream')})
785            m = file_stream()
786            r = self._session.post(
787                url, data=m, headers=CaseInsensitiveDict({'content-type': m.content_type, 'X-Atlassian-Token': 'nocheck'}), retry_data=file_stream)
788
789        js = json_loads(r)
790        if not js or not isinstance(js, collections.Iterable):
791            raise JIRAError("Unable to parse JSON: %s" % js)
792        attachment = Attachment(self._options, self._session, js[0])
793        if attachment.size == 0:
794            raise JIRAError("Added empty attachment via %s method?!: r: %s\nattachment: %s" % (method, r, attachment))
795        return attachment
796
797    def delete_attachment(self, id):
798        """Delete attachment by id.
799
800        :param id: ID of the attachment to delete
801        """
802        url = self._get_url('attachment/' + str(id))
803        return self._session.delete(url)
804
805    # Components
806
807    def component(self, id):
808        """Get a component Resource from the server.
809
810        :param id: ID of the component to get
811        """
812        return self._find_for_resource(Component, id)
813
814    @translate_resource_args
815    def create_component(self, name, project, description=None, leadUserName=None, assigneeType=None,
816                         isAssigneeTypeValid=False):
817        """Create a component inside a project and return a Resource for it.
818
819        :param name: name of the component
820        :param project: key of the project to create the component in
821        :param description: a description of the component
822        :param leadUserName: the username of the user responsible for this component
823        :param assigneeType: see the ComponentBean.AssigneeType class for valid values
824        :param isAssigneeTypeValid: boolean specifying whether the assignee type is acceptable
825        """
826        data = {
827            'name': name,
828            'project': project,
829            'isAssigneeTypeValid': isAssigneeTypeValid}
830        if description is not None:
831            data['description'] = description
832        if leadUserName is not None:
833            data['leadUserName'] = leadUserName
834        if assigneeType is not None:
835            data['assigneeType'] = assigneeType
836
837        url = self._get_url('component')
838        r = self._session.post(
839            url, data=json.dumps(data))
840
841        component = Component(self._options, self._session, raw=json_loads(r))
842        return component
843
844    def component_count_related_issues(self, id):
845        """Get the count of related issues for a component.
846
847        :type id: integer
848        :param id: ID of the component to use
849        """
850        return self._get_json('component/' + id + '/relatedIssueCounts')['issueCount']
851
852    def delete_component(self, id):
853        """Delete component by id.
854
855        :param id: ID of the component to use
856        """
857        url = self._get_url('component/' + str(id))
858        return self._session.delete(url)
859
860    # Custom field options
861
862    def custom_field_option(self, id):
863        """Get a custom field option Resource from the server.
864
865        :param id: ID of the custom field to use
866        """
867        return self._find_for_resource(CustomFieldOption, id)
868
869    # Dashboards
870
871    def dashboards(self, filter=None, startAt=0, maxResults=20):
872        """Return a ResultList of Dashboard resources and a ``total`` count.
873
874        :param filter: either "favourite" or "my", the type of dashboards to return
875        :param startAt: index of the first dashboard to return
876        :param maxResults: maximum number of dashboards to return.
877            If maxResults evaluates as False, it will try to get all items in batches.
878
879        :rtype: ResultList
880        """
881        params = {}
882        if filter is not None:
883            params['filter'] = filter
884        return self._fetch_pages(Dashboard, 'dashboards', 'dashboard', startAt, maxResults, params)
885
886    def dashboard(self, id):
887        """Get a dashboard Resource from the server.
888
889        :param id: ID of the dashboard to get.
890        """
891        return self._find_for_resource(Dashboard, id)
892
893    # Fields
894
895    # non-resource
896    def fields(self):
897        """Return a list of all issue fields."""
898        return self._get_json('field')
899
900    # Filters
901
902    def filter(self, id):
903        """Get a filter Resource from the server.
904
905        :param id: ID of the filter to get.
906        """
907        return self._find_for_resource(Filter, id)
908
909    def favourite_filters(self):
910        """Get a list of filter Resources which are the favourites of the currently authenticated user."""
911        r_json = self._get_json('filter/favourite')
912        filters = [Filter(self._options, self._session, raw_filter_json)
913                   for raw_filter_json in r_json]
914        return filters
915
916    def create_filter(self, name=None, description=None,
917                      jql=None, favourite=None):
918        """Create a new filter and return a filter Resource for it.
919
920        :param name: name of the new filter
921        :param description: useful human readable description of the new filter
922        :param jql: query string that defines the filter
923        :param favourite: whether to add this filter to the current user's favorites
924
925        """
926        data = {}
927        if name is not None:
928            data['name'] = name
929        if description is not None:
930            data['description'] = description
931        if jql is not None:
932            data['jql'] = jql
933        if favourite is not None:
934            data['favourite'] = favourite
935        url = self._get_url('filter')
936        r = self._session.post(
937            url, data=json.dumps(data))
938
939        raw_filter_json = json_loads(r)
940        return Filter(self._options, self._session, raw=raw_filter_json)
941
942    def update_filter(self, filter_id,
943                      name=None, description=None,
944                      jql=None, favourite=None):
945        """Update a filter and return a filter Resource for it.
946
947        :param name: name of the new filter
948        :param description: useful human readable description of the new filter
949        :param jql: query string that defines the filter
950        :param favourite: whether to add this filter to the current user's favorites
951
952        """
953        filter = self.filter(filter_id)
954        data = {}
955        data['name'] = name or filter.name
956        data['description'] = description or filter.description
957        data['jql'] = jql or filter.jql
958        data['favourite'] = favourite or filter.favourite
959
960        url = self._get_url('filter/%s' % filter_id)
961        r = self._session.put(url, headers={'content-type': 'application/json'},
962                              data=json.dumps(data))
963
964        raw_filter_json = json.loads(r.text)
965        return Filter(self._options, self._session, raw=raw_filter_json)
966
967# Groups
968
969    # non-resource
970    def groups(self, query=None, exclude=None, maxResults=9999):
971        """Return a list of groups matching the specified criteria.
972
973        :param query: filter groups by name with this string
974        :param exclude: filter out groups by name with this string
975        :param maxResults: maximum results to return. defaults to 9999
976        """
977        params = {}
978        groups = []
979        if query is not None:
980            params['query'] = query
981        if exclude is not None:
982            params['exclude'] = exclude
983        if maxResults is not None:
984            params['maxResults'] = maxResults
985        for group in self._get_json('groups/picker', params=params)['groups']:
986            groups.append(group['name'])
987        return sorted(groups)
988
989    def group_members(self, group):
990        """Return a hash or users with their information. Requires JIRA 6.0 or will raise NotImplemented."""
991        if self._version < (6, 0, 0):
992            raise NotImplementedError(
993                "Group members is not implemented in JIRA before version 6.0, upgrade the instance, if possible.")
994
995        params = {'groupname': group, 'expand': "users"}
996        r = self._get_json('group', params=params)
997        size = r['users']['size']
998        end_index = r['users']['end-index']
999
1000        while end_index < size - 1:
1001            params = {'groupname': group, 'expand': "users[%s:%s]" % (
1002                end_index + 1, end_index + 50)}
1003            r2 = self._get_json('group', params=params)
1004            for user in r2['users']['items']:
1005                r['users']['items'].append(user)
1006            end_index = r2['users']['end-index']
1007            size = r['users']['size']
1008
1009        result = {}
1010        for user in r['users']['items']:
1011            result[user['key']] = {'name': user['name'],
1012                                   'fullname': user['displayName'],
1013                                   'email': user.get('emailAddress', 'hidden'),
1014                                   'active': user['active']}
1015        return OrderedDict(sorted(result.items(), key=lambda t: t[0]))
1016
1017    def add_group(self, groupname):
1018        """Create a new group in JIRA.
1019
1020        :param groupname: The name of the group you wish to create.
1021        :return: Boolean - True if successful.
1022        """
1023        url = self._options['server'] + '/rest/api/latest/group'
1024
1025        # implementation based on
1026        # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173
1027
1028        x = OrderedDict()
1029
1030        x['name'] = groupname
1031
1032        payload = json.dumps(x)
1033
1034        self._session.post(url, data=payload)
1035
1036        return True
1037
1038    def remove_group(self, groupname):
1039        """Delete a group from the JIRA instance.
1040
1041        :param groupname: The group to be deleted from the JIRA instance.
1042        :return: Boolean. Returns True on success.
1043        """
1044        # implementation based on
1045        # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173
1046        url = self._options['server'] + '/rest/api/latest/group'
1047        x = {'groupname': groupname}
1048        self._session.delete(url, params=x)
1049        return True
1050
1051    # Issues
1052
1053    def issue(self, id, fields=None, expand=None):
1054        """Get an issue Resource from the server.
1055
1056        :param id: ID or key of the issue to get
1057        :param fields: comma-separated string of issue fields to include in the results
1058        :param expand: extra information to fetch inside each resource
1059        """
1060        # this allows us to pass Issue objects to issue()
1061        if isinstance(id, Issue):
1062            return id
1063
1064        issue = Issue(self._options, self._session)
1065
1066        params = {}
1067        if fields is not None:
1068            params['fields'] = fields
1069        if expand is not None:
1070            params['expand'] = expand
1071        issue.find(id, params=params)
1072        return issue
1073
1074    def create_issue(self, fields=None, prefetch=True, **fieldargs):
1075        """Create a new issue and return an issue Resource for it.
1076
1077        Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value
1078        is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments
1079        will be ignored.
1080
1081        By default, the client will immediately reload the issue Resource created by this method in order to return
1082        a complete Issue object to the caller; this behavior can be controlled through the 'prefetch' argument.
1083
1084        JIRA projects may contain many different issue types. Some issue screens have different requirements for
1085        fields in a new issue. This information is available through the 'createmeta' method. Further examples are
1086        available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue
1087
1088        :param fields: a dict containing field names and the values to use. If present, all other keyword arguments
1089            will be ignored
1090        :param prefetch: whether to reload the created issue Resource so that all of its data is present in the value
1091            returned from this method
1092        """
1093        data = _field_worker(fields, **fieldargs)
1094
1095        p = data['fields']['project']
1096
1097        if isinstance(p, string_types) or isinstance(p, integer_types):
1098            data['fields']['project'] = {'id': self.project(p).id}
1099
1100        p = data['fields']['issuetype']
1101        if isinstance(p, integer_types):
1102            data['fields']['issuetype'] = {'id': p}
1103        if isinstance(p, string_types) or isinstance(p, integer_types):
1104            data['fields']['issuetype'] = {'id': self.issue_type_by_name(p).id}
1105
1106        url = self._get_url('issue')
1107        r = self._session.post(url, data=json.dumps(data))
1108
1109        raw_issue_json = json_loads(r)
1110        if 'key' not in raw_issue_json:
1111            raise JIRAError(r.status_code, response=r, url=url, text=json.dumps(data))
1112        if prefetch:
1113            return self.issue(raw_issue_json['key'])
1114        else:
1115            return Issue(self._options, self._session, raw=raw_issue_json)
1116
1117    def create_issues(self, field_list, prefetch=True):
1118        """Bulk create new issues and return an issue Resource for each successfully created issue.
1119
1120        See `create_issue` documentation for field information.
1121
1122        :param field_list: a list of dicts each containing field names and the values to use. Each dict
1123            is an individual issue to create and is subject to its minimum requirements.
1124        :param prefetch: whether to reload the created issue Resource for each created issue so that all
1125            of its data is present in the value returned from this method.
1126        """
1127        data = {'issueUpdates': []}
1128        for field_dict in field_list:
1129            issue_data = _field_worker(field_dict)
1130            p = issue_data['fields']['project']
1131
1132            if isinstance(p, string_types) or isinstance(p, integer_types):
1133                issue_data['fields']['project'] = {'id': self.project(p).id}
1134
1135            p = issue_data['fields']['issuetype']
1136            if isinstance(p, integer_types):
1137                issue_data['fields']['issuetype'] = {'id': p}
1138            if isinstance(p, string_types) or isinstance(p, integer_types):
1139                issue_data['fields']['issuetype'] = {'id': self.issue_type_by_name(p).id}
1140
1141            data['issueUpdates'].append(issue_data)
1142
1143        url = self._get_url('issue/bulk')
1144        try:
1145            r = self._session.post(url, data=json.dumps(data))
1146            raw_issue_json = json_loads(r)
1147        # Catching case where none of the issues has been created. See https://github.com/pycontribs/jira/issues/350
1148        except JIRAError as je:
1149            if je.status_code == 400:
1150                raw_issue_json = json.loads(je.response.text)
1151            else:
1152                raise
1153        issue_list = []
1154        errors = {}
1155        for error in raw_issue_json['errors']:
1156            errors[error['failedElementNumber']] = error['elementErrors']['errors']
1157        for index, fields in enumerate(field_list):
1158            if index in errors:
1159                issue_list.append({'status': 'Error', 'error': errors[index],
1160                                   'issue': None, 'input_fields': fields})
1161            else:
1162                issue = raw_issue_json['issues'].pop(0)
1163                if prefetch:
1164                    issue = self.issue(issue['key'])
1165                else:
1166                    issue = Issue(self._options, self._session, raw=issue)
1167                issue_list.append({'status': 'Success', 'issue': issue,
1168                                   'error': None, 'input_fields': fields})
1169        return issue_list
1170
1171    def supports_service_desk(self):
1172        url = self._options['server'] + '/rest/servicedeskapi/info'
1173        headers = {'X-ExperimentalApi': 'opt-in'}
1174        try:
1175            r = self._session.get(url, headers=headers)
1176            return r.status_code == 200
1177        except JIRAError:
1178            return False
1179
1180    def create_customer(self, email, displayName):
1181        """Create a new customer and return an issue Resource for it."""
1182        url = self._options['server'] + '/rest/servicedeskapi/customer'
1183        headers = {'X-ExperimentalApi': 'opt-in'}
1184        r = self._session.post(url, headers=headers, data=json.dumps({
1185            'email': email,
1186            'displayName': displayName
1187        }))
1188
1189        raw_customer_json = json_loads(r)
1190
1191        if r.status_code != 201:
1192            raise JIRAError(r.status_code, request=r)
1193        return Customer(self._options, self._session, raw=raw_customer_json)
1194
1195    def service_desks(self):
1196        """Get a list of ServiceDesk Resources from the server visible to the current authenticated user."""
1197        url = self._options['server'] + '/rest/servicedeskapi/servicedesk'
1198        headers = {'X-ExperimentalApi': 'opt-in'}
1199        r_json = json_loads(self._session.get(url, headers=headers))
1200        projects = [ServiceDesk(self._options, self._session, raw_project_json)
1201                    for raw_project_json in r_json['values']]
1202        return projects
1203
1204    def service_desk(self, id):
1205        """Get a Service Desk Resource from the server.
1206
1207        :param id: ID or key of the Service Desk to get
1208        """
1209        return self._find_for_resource(ServiceDesk, id)
1210
1211    def create_customer_request(self, fields=None, prefetch=True, **fieldargs):
1212        """Create a new customer request and return an issue Resource for it.
1213
1214        Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value
1215        is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments
1216        will be ignored.
1217
1218        By default, the client will immediately reload the issue Resource created by this method in order to return
1219        a complete Issue object to the caller; this behavior can be controlled through the 'prefetch' argument.
1220
1221        JIRA projects may contain many different issue types. Some issue screens have different requirements for
1222        fields in a new issue. This information is available through the 'createmeta' method. Further examples are
1223        available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue
1224
1225        :param fields: a dict containing field names and the values to use. If present, all other keyword arguments
1226            will be ignored
1227        :param prefetch: whether to reload the created issue Resource so that all of its data is present in the value
1228            returned from this method
1229        """
1230        data = fields
1231
1232        p = data['serviceDeskId']
1233        service_desk = None
1234
1235        if isinstance(p, string_types) or isinstance(p, integer_types):
1236            service_desk = self.service_desk(p)
1237        elif isinstance(p, ServiceDesk):
1238            service_desk = p
1239
1240        data['serviceDeskId'] = service_desk.id
1241
1242        p = data['requestTypeId']
1243        if isinstance(p, integer_types):
1244            data['requestTypeId'] = p
1245        elif isinstance(p, string_types):
1246            data['requestTypeId'] = self.request_type_by_name(
1247                service_desk, p).id
1248
1249        url = self._options['server'] + '/rest/servicedeskapi/request'
1250        headers = {'X-ExperimentalApi': 'opt-in'}
1251        r = self._session.post(url, headers=headers, data=json.dumps(data))
1252
1253        raw_issue_json = json_loads(r)
1254        if 'issueKey' not in raw_issue_json:
1255            raise JIRAError(r.status_code, request=r)
1256        if prefetch:
1257            return self.issue(raw_issue_json['issueKey'])
1258        else:
1259            return Issue(self._options, self._session, raw=raw_issue_json)
1260
1261    def createmeta(self, projectKeys=None, projectIds=[], issuetypeIds=None, issuetypeNames=None, expand=None):
1262        """Get the metadata required to create issues, optionally filtered by projects and issue types.
1263
1264        :param projectKeys: keys of the projects to filter the results with.
1265            Can be a single value or a comma-delimited string. May be combined
1266            with projectIds.
1267        :param projectIds: IDs of the projects to filter the results with. Can
1268            be a single value or a comma-delimited string. May be combined with
1269            projectKeys.
1270        :param issuetypeIds: IDs of the issue types to filter the results with.
1271            Can be a single value or a comma-delimited string. May be combined
1272            with issuetypeNames.
1273        :param issuetypeNames: Names of the issue types to filter the results
1274            with. Can be a single value or a comma-delimited string. May be
1275            combined with issuetypeIds.
1276        :param expand: extra information to fetch inside each resource.
1277
1278        """
1279        params = {}
1280        if projectKeys is not None:
1281            params['projectKeys'] = projectKeys
1282        if projectIds is not None:
1283            if isinstance(projectIds, string_types):
1284                projectIds = projectIds.split(',')
1285            params['projectIds'] = projectIds
1286        if issuetypeIds is not None:
1287            params['issuetypeIds'] = issuetypeIds
1288        if issuetypeNames is not None:
1289            params['issuetypeNames'] = issuetypeNames
1290        if expand is not None:
1291            params['expand'] = expand
1292        return self._get_json('issue/createmeta', params)
1293
1294    # non-resource
1295    @translate_resource_args
1296    def assign_issue(self, issue, assignee):
1297        """Assign an issue to a user. None will set it to unassigned. -1 will set it to Automatic.
1298
1299        :param issue: the issue ID or key to assign
1300        :param assignee: the user to assign the issue to
1301
1302        :type issue: int or str
1303        :type assignee: str
1304
1305        :rtype: bool
1306        """
1307        url = self._options['server'] + \
1308            '/rest/api/latest/issue/' + str(issue) + '/assignee'
1309        payload = {'name': assignee}
1310        r = self._session.put(
1311            url, data=json.dumps(payload))
1312        raise_on_error(r)
1313        return True
1314
1315    @translate_resource_args
1316    def comments(self, issue):
1317        """Get a list of comment Resources.
1318
1319        :param issue: the issue to get comments from
1320        """
1321        r_json = self._get_json('issue/' + str(issue) + '/comment')
1322
1323        comments = [Comment(self._options, self._session, raw_comment_json)
1324                    for raw_comment_json in r_json['comments']]
1325        return comments
1326
1327    @translate_resource_args
1328    def comment(self, issue, comment):
1329        """Get a comment Resource from the server for the specified ID.
1330
1331        :param issue: ID or key of the issue to get the comment from
1332        :param comment: ID of the comment to get
1333        """
1334        return self._find_for_resource(Comment, (issue, comment))
1335
1336    @translate_resource_args
1337    def add_comment(self, issue, body, visibility=None, is_internal=False):
1338        """Add a comment from the current authenticated user on the specified issue and return a Resource for it.
1339
1340        The issue identifier and comment body are required.
1341
1342        :param issue: ID or key of the issue to add the comment to
1343        :param body: Text of the comment to add
1344        :param visibility: a dict containing two entries: "type" and "value".
1345            "type" is 'role' (or 'group' if the JIRA server has configured
1346            comment visibility for groups) and 'value' is the name of the role
1347            (or group) to which viewing of this comment will be restricted.
1348        :param is_internal: defines whether a comment has to be marked as 'Internal' in Jira Service Desk
1349        """
1350        data = {
1351            'body': body,
1352        }
1353
1354        if is_internal:
1355            data.update({
1356                'properties': [
1357                    {'key': 'sd.public.comment',
1358                     'value': {'internal': is_internal}}
1359                ]
1360            })
1361
1362        if visibility is not None:
1363            data['visibility'] = visibility
1364
1365        url = self._get_url('issue/' + str(issue) + '/comment')
1366        r = self._session.post(
1367            url, data=json.dumps(data)
1368        )
1369
1370        comment = Comment(self._options, self._session, raw=json_loads(r))
1371        return comment
1372
1373    # non-resource
1374    @translate_resource_args
1375    def editmeta(self, issue):
1376        """Get the edit metadata for an issue.
1377
1378        :param issue: the issue to get metadata for
1379        """
1380        return self._get_json('issue/' + str(issue) + '/editmeta')
1381
1382    @translate_resource_args
1383    def remote_links(self, issue):
1384        """Get a list of remote link Resources from an issue.
1385
1386        :param issue: the issue to get remote links from
1387        """
1388        r_json = self._get_json('issue/' + str(issue) + '/remotelink')
1389        remote_links = [RemoteLink(
1390            self._options, self._session, raw_remotelink_json) for raw_remotelink_json in r_json]
1391        return remote_links
1392
1393    @translate_resource_args
1394    def remote_link(self, issue, id):
1395        """Get a remote link Resource from the server.
1396
1397        :param issue: the issue holding the remote link
1398        :param id: ID of the remote link
1399        """
1400        return self._find_for_resource(RemoteLink, (issue, id))
1401
1402    # removed the @translate_resource_args because it prevents us from finding
1403    # information for building a proper link
1404    def add_remote_link(self, issue, destination, globalId=None, application=None, relationship=None):
1405        """Add a remote link from an issue to an external application and returns a remote link Resource for it.
1406
1407        ``object`` should be a dict containing at least ``url`` to the linked external URL and
1408        ``title`` to display for the link inside JIRA.
1409
1410        For definitions of the allowable fields for ``object`` and the keyword arguments ``globalId``, ``application``
1411        and ``relationship``, see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links.
1412
1413        :param issue: the issue to add the remote link to
1414        :param destination: the link details to add (see the above link for details)
1415        :param globalId: unique ID for the link (see the above link for details)
1416        :param application: application information for the link (see the above link for details)
1417        :param relationship: relationship description for the link (see the above link for details)
1418        """
1419        try:
1420            applicationlinks = self.applicationlinks()
1421        except JIRAError as e:
1422            applicationlinks = []
1423            # In many (if not most) configurations, non-admin users are
1424            # not allowed to list applicationlinks; if we aren't allowed,
1425            # let's let people try to add remote links anyway, we just
1426            # won't be able to be quite as helpful.
1427            warnings.warn(
1428                "Unable to gather applicationlinks; you will not be able "
1429                "to add links to remote issues: (%s) %s" % (
1430                    e.status_code,
1431                    e.text),
1432                Warning)
1433
1434        data = {}
1435        if isinstance(destination, Issue):
1436
1437            data['object'] = {
1438                'title': str(destination),
1439                'url': destination.permalink()}
1440
1441            for x in applicationlinks:
1442                if x['application']['displayUrl'] == destination._options['server']:
1443                    data['globalId'] = "appId=%s&issueId=%s" % (
1444                        x['application']['id'], destination.raw['id'])
1445                    data['application'] = {
1446                        'name': x['application']['name'], 'type': "com.atlassian.jira"}
1447                    break
1448            if 'globalId' not in data:
1449                raise NotImplementedError(
1450                    "Unable to identify the issue to link to.")
1451        else:
1452
1453            if globalId is not None:
1454                data['globalId'] = globalId
1455            if application is not None:
1456                data['application'] = application
1457            data['object'] = destination
1458
1459        if relationship is not None:
1460            data['relationship'] = relationship
1461
1462        # check if the link comes from one of the configured application links
1463        for x in applicationlinks:
1464            if x['application']['displayUrl'] == self._options['server']:
1465                data['globalId'] = "appId=%s&issueId=%s" % (
1466                    x['application']['id'], destination.raw['id'])
1467                data['application'] = {
1468                    'name': x['application']['name'], 'type': "com.atlassian.jira"}
1469                break
1470
1471        url = self._get_url('issue/' + str(issue) + '/remotelink')
1472        r = self._session.post(
1473            url, data=json.dumps(data))
1474
1475        remote_link = RemoteLink(
1476            self._options, self._session, raw=json_loads(r))
1477        return remote_link
1478
1479    def add_simple_link(self, issue, object):
1480        """Add a simple remote link from an issue to web resource.
1481
1482        This avoids the admin access problems from add_remote_link by just
1483            using a simple object and presuming all fields are correct and not
1484            requiring more complex ``application`` data.
1485
1486        ``object`` should be a dict containing at least ``url`` to the
1487            linked external URL and ``title`` to display for the link inside JIRA.
1488
1489        For definitions of the allowable fields for ``object`` , see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links.
1490
1491        :param issue: the issue to add the remote link to
1492        :param object: the dictionary used to create remotelink data
1493        """
1494        data = {"object": object}
1495        url = self._get_url('issue/' + str(issue) + '/remotelink')
1496        r = self._session.post(
1497            url, data=json.dumps(data))
1498
1499        simple_link = RemoteLink(
1500            self._options, self._session, raw=json_loads(r))
1501        return simple_link
1502
1503    # non-resource
1504    @translate_resource_args
1505    def transitions(self, issue, id=None, expand=None):
1506        """Get a list of the transitions available on the specified issue to the current user.
1507
1508        :param issue: ID or key of the issue to get the transitions from
1509        :param id: if present, get only the transition matching this ID
1510        :param expand: extra information to fetch inside each transition
1511        """
1512        params = {}
1513        if id is not None:
1514            params['transitionId'] = id
1515        if expand is not None:
1516            params['expand'] = expand
1517        return self._get_json('issue/' + str(issue) + '/transitions', params=params)['transitions']
1518
1519    def find_transitionid_by_name(self, issue, transition_name):
1520        """Get a transitionid available on the specified issue to the current user.
1521
1522        Look at https://developer.atlassian.com/static/rest/jira/6.1.html#d2e1074 for json reference
1523
1524        :param issue: ID or key of the issue to get the transitions from
1525        :param trans_name: iname of transition we are looking for
1526        """
1527        transitions_json = self.transitions(issue)
1528        id = None
1529
1530        for transition in transitions_json:
1531            if transition["name"].lower() == transition_name.lower():
1532                id = transition["id"]
1533                break
1534        return id
1535
1536    @translate_resource_args
1537    def transition_issue(self, issue, transition, fields=None, comment=None, worklog=None, **fieldargs):
1538        """Perform a transition on an issue.
1539
1540        Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value
1541        is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments
1542        will be ignored. Field values will be set on the issue as part of the transition process.
1543
1544        :param issue: ID or key of the issue to perform the transition on
1545        :param transition: ID or name of the transition to perform
1546        :param comment: *Optional* String to add as comment to the issue when
1547            performing the transition.
1548        :param fields: a dict containing field names and the values to use.
1549            If present, all other keyword arguments will be ignored
1550        """
1551        transitionId = None
1552
1553        try:
1554            transitionId = int(transition)
1555        except Exception:
1556            # cannot cast to int, so try to find transitionId by name
1557            transitionId = self.find_transitionid_by_name(issue, transition)
1558            if transitionId is None:
1559                raise JIRAError("Invalid transition name. %s" % transition)
1560
1561        data = {
1562            'transition': {
1563                'id': transitionId}}
1564        if comment:
1565            data['update'] = {'comment': [{'add': {'body': comment}}]}
1566        if worklog:
1567            data['update'] = {'worklog': [{'add': {'timeSpent': worklog}}]}
1568        if fields is not None:
1569            data['fields'] = fields
1570        else:
1571            fields_dict = {}
1572            for field in fieldargs:
1573                fields_dict[field] = fieldargs[field]
1574            data['fields'] = fields_dict
1575
1576        url = self._get_url('issue/' + str(issue) + '/transitions')
1577        r = self._session.post(
1578            url, data=json.dumps(data))
1579        try:
1580            r_json = json_loads(r)
1581        except ValueError as e:
1582            logging.error("%s\n%s" % (e, r.text))
1583            raise e
1584        return r_json
1585
1586    @translate_resource_args
1587    def votes(self, issue):
1588        """Get a votes Resource from the server.
1589
1590        :param issue: ID or key of the issue to get the votes for
1591        """
1592        return self._find_for_resource(Votes, issue)
1593
1594    @translate_resource_args
1595    def add_vote(self, issue):
1596        """Register a vote for the current authenticated user on an issue.
1597
1598        :param issue: ID or key of the issue to vote on
1599        """
1600        url = self._get_url('issue/' + str(issue) + '/votes')
1601        return self._session.post(url)
1602
1603    @translate_resource_args
1604    def remove_vote(self, issue):
1605        """Remove the current authenticated user's vote from an issue.
1606
1607        :param issue: ID or key of the issue to remove vote on
1608        """
1609        url = self._get_url('issue/' + str(issue) + '/votes')
1610        self._session.delete(url)
1611
1612    @translate_resource_args
1613    def watchers(self, issue):
1614        """Get a watchers Resource from the server for an issue.
1615
1616        :param issue: ID or key of the issue to get the watchers for
1617        """
1618        return self._find_for_resource(Watchers, issue)
1619
1620    @translate_resource_args
1621    def add_watcher(self, issue, watcher):
1622        """Add a user to an issue's watchers list.
1623
1624        :param issue: ID or key of the issue affected
1625        :param watcher: username of the user to add to the watchers list
1626        """
1627        url = self._get_url('issue/' + str(issue) + '/watchers')
1628        self._session.post(
1629            url, data=json.dumps(watcher))
1630
1631    @translate_resource_args
1632    def remove_watcher(self, issue, watcher):
1633        """Remove a user from an issue's watch list.
1634
1635        :param issue: ID or key of the issue affected
1636        :param watcher: username of the user to remove from the watchers list
1637        """
1638        url = self._get_url('issue/' + str(issue) + '/watchers')
1639        params = {'username': watcher}
1640        result = self._session.delete(url, params=params)
1641        return result
1642
1643    @translate_resource_args
1644    def worklogs(self, issue):
1645        """Get a list of worklog Resources from the server for an issue.
1646
1647        :param issue: ID or key of the issue to get worklogs from
1648        """
1649        r_json = self._get_json('issue/' + str(issue) + '/worklog')
1650        worklogs = [Worklog(self._options, self._session, raw_worklog_json)
1651                    for raw_worklog_json in r_json['worklogs']]
1652        return worklogs
1653
1654    @translate_resource_args
1655    def worklog(self, issue, id):
1656        """Get a specific worklog Resource from the server.
1657
1658        :param issue: ID or key of the issue to get the worklog from
1659        :param id: ID of the worklog to get
1660        """
1661        return self._find_for_resource(Worklog, (issue, id))
1662
1663    @translate_resource_args
1664    def add_worklog(self, issue, timeSpent=None, timeSpentSeconds=None, adjustEstimate=None,
1665                    newEstimate=None, reduceBy=None, comment=None, started=None, user=None):
1666        """Add a new worklog entry on an issue and return a Resource for it.
1667
1668        :param issue: the issue to add the worklog to
1669        :param timeSpent: a worklog entry with this amount of time spent, e.g. "2d"
1670        :param adjustEstimate: (optional) allows the user to provide specific instructions to update the remaining
1671            time estimate of the issue. The value can either be ``new``, ``leave``, ``manual`` or ``auto`` (default).
1672        :param newEstimate: the new value for the remaining estimate field. e.g. "2d"
1673        :param reduceBy: the amount to reduce the remaining estimate by e.g. "2d"
1674        :param started: Moment when the work is logged, if not specified will default to now
1675        :param comment: optional worklog comment
1676        """
1677        params = {}
1678        if adjustEstimate is not None:
1679            params['adjustEstimate'] = adjustEstimate
1680        if newEstimate is not None:
1681            params['newEstimate'] = newEstimate
1682        if reduceBy is not None:
1683            params['reduceBy'] = reduceBy
1684
1685        data = {}
1686        if timeSpent is not None:
1687            data['timeSpent'] = timeSpent
1688        if timeSpentSeconds is not None:
1689            data['timeSpentSeconds'] = timeSpentSeconds
1690        if comment is not None:
1691            data['comment'] = comment
1692        elif user:
1693            # we log user inside comment as it doesn't always work
1694            data['comment'] = user
1695
1696        if started is not None:
1697            # based on REST Browser it needs: "2014-06-03T08:21:01.273+0000"
1698            data['started'] = started.strftime("%Y-%m-%dT%H:%M:%S.000+0000%z")
1699        if user is not None:
1700            data['author'] = {"name": user,
1701                              'self': self.JIRA_BASE_URL + '/rest/api/latest/user?username=' + user,
1702                              'displayName': user,
1703                              'active': False
1704                              }
1705            data['updateAuthor'] = data['author']
1706        # report bug to Atlassian: author and updateAuthor parameters are
1707        # ignored.
1708        url = self._get_url('issue/{0}/worklog'.format(issue))
1709        r = self._session.post(url, params=params, data=json.dumps(data))
1710
1711        return Worklog(self._options, self._session, json_loads(r))
1712
1713    # Issue links
1714
1715    @translate_resource_args
1716    def create_issue_link(self, type, inwardIssue, outwardIssue, comment=None):
1717        """Create a link between two issues.
1718
1719        :param type: the type of link to create
1720        :param inwardIssue: the issue to link from
1721        :param outwardIssue: the issue to link to
1722        :param comment:  a comment to add to the issues with the link. Should be
1723            a dict containing ``body`` and ``visibility`` fields: ``body`` being
1724            the text of the comment and ``visibility`` being a dict containing
1725            two entries: ``type`` and ``value``. ``type`` is ``role`` (or
1726            ``group`` if the JIRA server has configured comment visibility for
1727            groups) and ``value`` is the name of the role (or group) to which
1728            viewing of this comment will be restricted.
1729        """
1730        # let's see if we have the right issue link 'type' and fix it if needed
1731        if not hasattr(self, '_cached_issuetypes'):
1732            self._cached_issue_link_types = self.issue_link_types()
1733
1734        if type not in self._cached_issue_link_types:
1735            for lt in self._cached_issue_link_types:
1736                if lt.outward == type:
1737                    # we are smart to figure it out what he meant
1738                    type = lt.name
1739                    break
1740                elif lt.inward == type:
1741                    # so that's the reverse, so we fix the request
1742                    type = lt.name
1743                    inwardIssue, outwardIssue = outwardIssue, inwardIssue
1744                    break
1745
1746        data = {
1747            'type': {
1748                'name': type},
1749            'inwardIssue': {
1750                'key': inwardIssue},
1751            'outwardIssue': {
1752                'key': outwardIssue},
1753            'comment': comment}
1754        url = self._get_url('issueLink')
1755        return self._session.post(
1756            url, data=json.dumps(data))
1757
1758    def delete_issue_link(self, id):
1759        """Delete a link between two issues.
1760
1761        :param id: ID of the issue link to delete
1762        """
1763        url = self._get_url('issueLink') + "/" + id
1764        return self._session.delete(url)
1765
1766    def issue_link(self, id):
1767        """Get an issue link Resource from the server.
1768
1769        :param id: ID of the issue link to get
1770        """
1771        return self._find_for_resource(IssueLink, id)
1772
1773    # Issue link types
1774
1775    def issue_link_types(self):
1776        """Get a list of issue link type Resources from the server."""
1777        r_json = self._get_json('issueLinkType')
1778        link_types = [IssueLinkType(self._options, self._session, raw_link_json) for raw_link_json in
1779                      r_json['issueLinkTypes']]
1780        return link_types
1781
1782    def issue_link_type(self, id):
1783        """Get an issue link type Resource from the server.
1784
1785        :param id: ID of the issue link type to get
1786        """
1787        return self._find_for_resource(IssueLinkType, id)
1788
1789    # Issue types
1790
1791    def issue_types(self):
1792        """Get a list of issue type Resources from the server."""
1793        r_json = self._get_json('issuetype')
1794        issue_types = [IssueType(
1795            self._options, self._session, raw_type_json) for raw_type_json in r_json]
1796        return issue_types
1797
1798    def issue_type(self, id):
1799        """Get an issue type Resource from the server.
1800
1801        :param id: ID of the issue type to get
1802        """
1803        return self._find_for_resource(IssueType, id)
1804
1805    def issue_type_by_name(self, name):
1806        issue_types = self.issue_types()
1807        try:
1808            issue_type = [it for it in issue_types if it.name == name][0]
1809        except IndexError:
1810            raise KeyError("Issue type '%s' is unknown." % name)
1811        return issue_type
1812
1813    def request_types(self, service_desk):
1814        if hasattr(service_desk, 'id'):
1815            service_desk = service_desk.id
1816        url = (self._options['server'] +
1817               '/rest/servicedeskapi/servicedesk/%s/requesttype'
1818               % service_desk)
1819        headers = {'X-ExperimentalApi': 'opt-in'}
1820        r_json = json_loads(self._session.get(url, headers=headers))
1821        request_types = [
1822            RequestType(self._options, self._session, raw_type_json)
1823            for raw_type_json in r_json['values']]
1824        return request_types
1825
1826    def request_type_by_name(self, service_desk, name):
1827        request_types = self.request_types(service_desk)
1828        try:
1829            request_type = [rt for rt in request_types if rt.name == name][0]
1830        except IndexError:
1831            raise KeyError("Request type '%s' is unknown." % name)
1832        return request_type
1833
1834    # User permissions
1835
1836    # non-resource
1837    def my_permissions(self, projectKey=None, projectId=None, issueKey=None, issueId=None):
1838        """Get a dict of all available permissions on the server.
1839
1840        :param projectKey: limit returned permissions to the specified project
1841        :param projectId: limit returned permissions to the specified project
1842        :param issueKey: limit returned permissions to the specified issue
1843        :param issueId: limit returned permissions to the specified issue
1844        """
1845        params = {}
1846        if projectKey is not None:
1847            params['projectKey'] = projectKey
1848        if projectId is not None:
1849            params['projectId'] = projectId
1850        if issueKey is not None:
1851            params['issueKey'] = issueKey
1852        if issueId is not None:
1853            params['issueId'] = issueId
1854        return self._get_json('mypermissions', params=params)
1855
1856    # Priorities
1857
1858    def priorities(self):
1859        """Get a list of priority Resources from the server."""
1860        r_json = self._get_json('priority')
1861        priorities = [Priority(
1862            self._options, self._session, raw_priority_json) for raw_priority_json in r_json]
1863        return priorities
1864
1865    def priority(self, id):
1866        """Get a priority Resource from the server.
1867
1868        :param id: ID of the priority to get
1869        """
1870        return self._find_for_resource(Priority, id)
1871
1872    # Projects
1873
1874    def projects(self):
1875        """Get a list of project Resources from the server visible to the current authenticated user."""
1876        r_json = self._get_json('project')
1877        projects = [Project(
1878            self._options, self._session, raw_project_json) for raw_project_json in r_json]
1879        return projects
1880
1881    def project(self, id):
1882        """Get a project Resource from the server.
1883
1884        :param id: ID or key of the project to get
1885        """
1886        return self._find_for_resource(Project, id)
1887
1888    # non-resource
1889    @translate_resource_args
1890    def project_avatars(self, project):
1891        """Get a dict of all avatars for a project visible to the current authenticated user.
1892
1893        :param project: ID or key of the project to get avatars for
1894        """
1895        return self._get_json('project/' + project + '/avatars')
1896
1897    @translate_resource_args
1898    def create_temp_project_avatar(self, project, filename, size, avatar_img, contentType=None, auto_confirm=False):
1899        """Register an image file as a project avatar.
1900
1901        The avatar created is temporary and must be confirmed before it can
1902            be used.
1903
1904        Avatar images are specified by a filename, size, and file object. By default, the client will attempt to
1905            autodetect the picture's content type: this mechanism relies on libmagic and will not work out of the box
1906            on Windows systems (see http://filemagic.readthedocs.org/en/latest/guide.html for details on how to install
1907            support). The ``contentType`` argument can be used to explicitly set the value (note that JIRA will reject any
1908            type other than the well-known ones for images, e.g. ``image/jpg``, ``image/png``, etc.)
1909
1910        This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This
1911            dict should be saved and passed to :py:meth:`confirm_project_avatar` to finish the avatar creation process. If
1912            you want to cut out the middleman and confirm the avatar with JIRA's default cropping, pass the 'auto_confirm'
1913            argument with a truthy value and :py:meth:`confirm_project_avatar` will be called for you before this method
1914            returns.
1915
1916        :param project: ID or key of the project to create the avatar in
1917        :param filename: name of the avatar file
1918        :param size: size of the avatar file
1919        :param avatar_img: file-like object holding the avatar
1920        :param contentType: explicit specification for the avatar image's content-type
1921        :param boolean auto_confirm: whether to automatically confirm the temporary avatar by calling
1922            :py:meth:`confirm_project_avatar` with the return value of this method.
1923        """
1924        size_from_file = os.path.getsize(filename)
1925        if size != size_from_file:
1926            size = size_from_file
1927
1928        params = {
1929            'filename': filename,
1930            'size': size}
1931
1932        headers = {'X-Atlassian-Token': 'no-check'}
1933        if contentType is not None:
1934            headers['content-type'] = contentType
1935        else:
1936            # try to detect content-type, this may return None
1937            headers['content-type'] = self._get_mime_type(avatar_img)
1938
1939        url = self._get_url('project/' + project + '/avatar/temporary')
1940        r = self._session.post(
1941            url, params=params, headers=headers, data=avatar_img)
1942
1943        cropping_properties = json_loads(r)
1944        if auto_confirm:
1945            return self.confirm_project_avatar(project, cropping_properties)
1946        else:
1947            return cropping_properties
1948
1949    @translate_resource_args
1950    def confirm_project_avatar(self, project, cropping_properties):
1951        """Confirm the temporary avatar image previously uploaded with the specified cropping.
1952
1953        After a successful registry with :py:meth:`create_temp_project_avatar`, use this method to confirm the avatar
1954        for use. The final avatar can be a subarea of the uploaded image, which is customized with the
1955        ``cropping_properties``: the return value of :py:meth:`create_temp_project_avatar` should be used for this
1956        argument.
1957
1958        :param project: ID or key of the project to confirm the avatar in
1959        :param cropping_properties: a dict of cropping properties from :py:meth:`create_temp_project_avatar`
1960        """
1961        data = cropping_properties
1962        url = self._get_url('project/' + project + '/avatar')
1963        r = self._session.post(
1964            url, data=json.dumps(data))
1965
1966        return json_loads(r)
1967
1968    @translate_resource_args
1969    def set_project_avatar(self, project, avatar):
1970        """Set a project's avatar.
1971
1972        :param project: ID or key of the project to set the avatar on
1973        :param avatar: ID of the avatar to set
1974        """
1975        self._set_avatar(
1976            None, self._get_url('project/' + project + '/avatar'), avatar)
1977
1978    @translate_resource_args
1979    def delete_project_avatar(self, project, avatar):
1980        """Delete a project's avatar.
1981
1982        :param project: ID or key of the project to delete the avatar from
1983        :param avatar: ID of the avatar to delete
1984        """
1985        url = self._get_url('project/' + project + '/avatar/' + avatar)
1986        return self._session.delete(url)
1987
1988    @translate_resource_args
1989    def project_components(self, project):
1990        """Get a list of component Resources present on a project.
1991
1992        :param project: ID or key of the project to get components from
1993        """
1994        r_json = self._get_json('project/' + project + '/components')
1995        components = [Component(
1996            self._options, self._session, raw_comp_json) for raw_comp_json in r_json]
1997        return components
1998
1999    @translate_resource_args
2000    def project_versions(self, project):
2001        """Get a list of version Resources present on a project.
2002
2003        :param project: ID or key of the project to get versions from
2004        """
2005        r_json = self._get_json('project/' + project + '/versions')
2006        versions = [
2007            Version(self._options, self._session, raw_ver_json) for raw_ver_json in r_json]
2008        return versions
2009
2010    # non-resource
2011    @translate_resource_args
2012    def project_roles(self, project):
2013        """Get a dict of role names to resource locations for a project.
2014
2015        :param project: ID or key of the project to get roles from
2016        """
2017        path = 'project/' + project + '/role'
2018        _rolesdict = self._get_json(path)
2019        rolesdict = {}
2020
2021        for k, v in _rolesdict.items():
2022            tmp = {}
2023            tmp['id'] = v.split("/")[-1]
2024            tmp['url'] = v
2025            rolesdict[k] = tmp
2026        return rolesdict
2027        # TODO(ssbarnea): return a list of Roles()
2028
2029    @translate_resource_args
2030    def project_role(self, project, id):
2031        """Get a role Resource.
2032
2033        :param project: ID or key of the project to get the role from
2034        :param id: ID of the role to get
2035        """
2036        if isinstance(id, Number):
2037            id = "%s" % id
2038        return self._find_for_resource(Role, (project, id))
2039
2040    # Resolutions
2041
2042    def resolutions(self):
2043        """Get a list of resolution Resources from the server."""
2044        r_json = self._get_json('resolution')
2045        resolutions = [Resolution(
2046            self._options, self._session, raw_res_json) for raw_res_json in r_json]
2047        return resolutions
2048
2049    def resolution(self, id):
2050        """Get a resolution Resource from the server.
2051
2052        :param id: ID of the resolution to get
2053        """
2054        return self._find_for_resource(Resolution, id)
2055
2056    # Search
2057
2058    def search_issues(self, jql_str, startAt=0, maxResults=50, validate_query=True, fields=None, expand=None,
2059                      json_result=None):
2060        """Get a :class:`~jira.client.ResultList` of issue Resources matching a JQL search string.
2061
2062        :param jql_str: the JQL search string to use
2063        :param startAt: index of the first issue to return
2064        :param maxResults: maximum number of issues to return. Total number of results
2065            is available in the ``total`` attribute of the returned :class:`~jira.client.ResultList`.
2066            If maxResults evaluates as False, it will try to get all issues in batches.
2067        :param fields: comma-separated string of issue fields to include in the results
2068        :param expand: extra information to fetch inside each resource
2069        :param json_result: JSON response will be returned when this parameter is set to True.
2070                Otherwise, :class:`~jira.client.ResultList` will be returned.
2071
2072        :type jql_str: str
2073        :type startAt: int
2074        :type maxResults: int
2075        :type fields: str
2076        :type expand: str
2077        :type json_result: bool
2078
2079        :rtype: dict or :class:`~jira.client.ResultList`
2080        """
2081        if fields is None:
2082            fields = []
2083        elif isinstance(fields, list):
2084            fields = fields.copy()
2085        elif isinstance(fields, string_types):
2086            fields = fields.split(",")
2087
2088        # this will translate JQL field names to REST API Name
2089        # most people do know the JQL names so this will help them use the API easier
2090        untranslate = {}  # use to add friendly aliases when we get the results back
2091        if self._fields:
2092            for i, field in enumerate(fields):
2093                if field in self._fields:
2094                    untranslate[self._fields[field]] = fields[i]
2095                    fields[i] = self._fields[field]
2096
2097        search_params = {
2098            "jql": jql_str,
2099            "startAt": startAt,
2100            "validateQuery": validate_query,
2101            "fields": fields,
2102            "expand": expand}
2103        if json_result:
2104            search_params["maxResults"] = maxResults
2105            if not maxResults:
2106                warnings.warn('All issues cannot be fetched at once, when json_result parameter is set', Warning)
2107            return self._get_json('search', params=search_params)
2108
2109        issues = self._fetch_pages(Issue, 'issues', 'search', startAt, maxResults, search_params)
2110
2111        if untranslate:
2112            for i in issues:
2113                for k, v in iteritems(untranslate):
2114                    if k in i.raw.get('fields', {}):
2115                        i.raw['fields'][v] = i.raw['fields'][k]
2116
2117        return issues
2118
2119    # Security levels
2120    def security_level(self, id):
2121        """Get a security level Resource.
2122
2123        :param id: ID of the security level to get
2124        """
2125        return self._find_for_resource(SecurityLevel, id)
2126
2127    # Server info
2128
2129    # non-resource
2130    def server_info(self):
2131        """Get a dict of server information for this JIRA instance."""
2132        retry = 0
2133        j = self._get_json('serverInfo')
2134        while not j and retry < 3:
2135            logging.warning("Bug https://jira.atlassian.com/browse/JRA-59676 trying again...")
2136            retry += 1
2137            j = self._get_json('serverInfo')
2138        return j
2139
2140    def myself(self):
2141        """Get a dict of server information for this JIRA instance."""
2142        return self._get_json('myself')
2143
2144    # Status
2145
2146    def statuses(self):
2147        """Get a list of status Resources from the server."""
2148        r_json = self._get_json('status')
2149        statuses = [Status(self._options, self._session, raw_stat_json)
2150                    for raw_stat_json in r_json]
2151        return statuses
2152
2153    def status(self, id):
2154        """Get a status Resource from the server.
2155
2156        :param id: ID of the status resource to get
2157        """
2158        return self._find_for_resource(Status, id)
2159
2160    # Users
2161
2162    def user(self, id, expand=None):
2163        """Get a user Resource from the server.
2164
2165        :param id: ID of the user to get
2166        :param expand: extra information to fetch inside each resource
2167        """
2168        user = User(self._options, self._session)
2169        params = {}
2170        if expand is not None:
2171            params['expand'] = expand
2172        user.find(id, params=params)
2173        return user
2174
2175    def search_assignable_users_for_projects(self, username, projectKeys, startAt=0, maxResults=50):
2176        """Get a list of user Resources that match the search string and can be assigned issues for projects.
2177
2178        :param username: a string to match usernames against
2179        :param projectKeys: comma-separated list of project keys to check for issue assignment permissions
2180        :param startAt: index of the first user to return
2181        :param maxResults: maximum number of users to return.
2182                If maxResults evaluates as False, it will try to get all users in batches.
2183        """
2184        params = {
2185            'username': username,
2186            'projectKeys': projectKeys}
2187        return self._fetch_pages(User, None, 'user/assignable/multiProjectSearch', startAt, maxResults, params)
2188
2189    def search_assignable_users_for_issues(self, username, project=None, issueKey=None, expand=None, startAt=0,
2190                                           maxResults=50):
2191        """Get a list of user Resources that match the search string for assigning or creating issues.
2192
2193        This method is intended to find users that are eligible to create issues in a project or be assigned
2194        to an existing issue. When searching for eligible creators, specify a project. When searching for eligible
2195        assignees, specify an issue key.
2196
2197        :param username: a string to match usernames against
2198        :param project: filter returned users by permission in this project (expected if a result will be used to
2199            create an issue)
2200        :param issueKey: filter returned users by this issue (expected if a result will be used to edit this issue)
2201        :param expand: extra information to fetch inside each resource
2202        :param startAt: index of the first user to return
2203        :param maxResults: maximum number of users to return.
2204                If maxResults evaluates as False, it will try to get all items in batches.
2205        """
2206        params = {
2207            'username': username}
2208        if project is not None:
2209            params['project'] = project
2210        if issueKey is not None:
2211            params['issueKey'] = issueKey
2212        if expand is not None:
2213            params['expand'] = expand
2214        return self._fetch_pages(User, None, 'user/assignable/search', startAt, maxResults, params)
2215
2216    # non-resource
2217    def user_avatars(self, username):
2218        """Get a dict of avatars for the specified user.
2219
2220        :param username: the username to get avatars for
2221        """
2222        return self._get_json('user/avatars', params={'username': username})
2223
2224    def create_temp_user_avatar(self, user, filename, size, avatar_img, contentType=None, auto_confirm=False):
2225        """Register an image file as a user avatar.
2226
2227        The avatar created is temporary and must be confirmed before it can
2228        be used.
2229
2230        Avatar images are specified by a filename, size, and file object. By default, the client will attempt to
2231        autodetect the picture's content type: this mechanism relies on ``libmagic`` and will not work out of the box
2232        on Windows systems (see http://filemagic.readthedocs.org/en/latest/guide.html for details on how to install
2233        support). The ``contentType`` argument can be used to explicitly set the value (note that JIRA will reject any
2234        type other than the well-known ones for images, e.g. ``image/jpg``, ``image/png``, etc.)
2235
2236        This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This
2237        dict should be saved and passed to :py:meth:`confirm_user_avatar` to finish the avatar creation process. If you
2238        want to cut out the middleman and confirm the avatar with JIRA's default cropping, pass the ``auto_confirm``
2239        argument with a truthy value and :py:meth:`confirm_user_avatar` will be called for you before this method
2240        returns.
2241
2242        :param user: user to register the avatar for
2243        :param filename: name of the avatar file
2244        :param size: size of the avatar file
2245        :param avatar_img: file-like object containing the avatar
2246        :param contentType: explicit specification for the avatar image's content-type
2247        :param auto_confirm: whether to automatically confirm the temporary avatar by calling
2248            :py:meth:`confirm_user_avatar` with the return value of this method.
2249        """
2250        size_from_file = os.path.getsize(filename)
2251        if size != size_from_file:
2252            size = size_from_file
2253
2254        # remove path from filename
2255        filename = os.path.split(filename)[1]
2256
2257        params = {
2258            'username': user,
2259            'filename': filename,
2260            'size': size}
2261
2262        headers = {'X-Atlassian-Token': 'no-check'}
2263        if contentType is not None:
2264            headers['content-type'] = contentType
2265        else:
2266            # try to detect content-type, this may return None
2267            headers['content-type'] = self._get_mime_type(avatar_img)
2268
2269        url = self._get_url('user/avatar/temporary')
2270        r = self._session.post(
2271            url, params=params, headers=headers, data=avatar_img)
2272
2273        cropping_properties = json_loads(r)
2274        if auto_confirm:
2275            return self.confirm_user_avatar(user, cropping_properties)
2276        else:
2277            return cropping_properties
2278
2279    def confirm_user_avatar(self, user, cropping_properties):
2280        """Confirm the temporary avatar image previously uploaded with the specified cropping.
2281
2282        After a successful registry with :py:meth:`create_temp_user_avatar`, use this method to confirm the avatar for
2283        use. The final avatar can be a subarea of the uploaded image, which is customized with the
2284        ``cropping_properties``: the return value of :py:meth:`create_temp_user_avatar` should be used for this
2285        argument.
2286
2287        :param user: the user to confirm the avatar for
2288        :param cropping_properties: a dict of cropping properties from :py:meth:`create_temp_user_avatar`
2289        """
2290        data = cropping_properties
2291        url = self._get_url('user/avatar')
2292        r = self._session.post(url, params={'username': user},
2293                               data=json.dumps(data))
2294
2295        return json_loads(r)
2296
2297    def set_user_avatar(self, username, avatar):
2298        """Set a user's avatar.
2299
2300        :param username: the user to set the avatar for
2301        :param avatar: ID of the avatar to set
2302        """
2303        self._set_avatar(
2304            {'username': username}, self._get_url('user/avatar'), avatar)
2305
2306    def delete_user_avatar(self, username, avatar):
2307        """Delete a user's avatar.
2308
2309        :param username: the user to delete the avatar from
2310        :param avatar: ID of the avatar to remove
2311        """
2312        params = {'username': username}
2313        url = self._get_url('user/avatar/' + avatar)
2314        return self._session.delete(url, params=params)
2315
2316    def search_users(self, user, startAt=0, maxResults=50, includeActive=True, includeInactive=False):
2317        """Get a list of user Resources that match the specified search string.
2318
2319        :param user: a string to match usernames, name or email against.
2320        :param startAt: index of the first user to return.
2321        :param maxResults: maximum number of users to return.
2322                If maxResults evaluates as False, it will try to get all items in batches.
2323        :param includeActive: If true, then active users are included in the results.
2324        :param includeInactive: If true, then inactive users are included in the results.
2325        """
2326        params = {
2327            'username': user,
2328            'includeActive': includeActive,
2329            'includeInactive': includeInactive}
2330        return self._fetch_pages(User, None, 'user/search', startAt, maxResults, params)
2331
2332    def search_allowed_users_for_issue(self, user, issueKey=None, projectKey=None, startAt=0, maxResults=50):
2333        """Get a list of user Resources that match a username string and have browse permission for the issue or project.
2334
2335        :param user: a string to match usernames against.
2336        :param issueKey: find users with browse permission for this issue.
2337        :param projectKey: find users with browse permission for this project.
2338        :param startAt: index of the first user to return.
2339        :param maxResults: maximum number of users to return.
2340                If maxResults evaluates as False, it will try to get all items in batches.
2341        """
2342        params = {
2343            'username': user}
2344        if issueKey is not None:
2345            params['issueKey'] = issueKey
2346        if projectKey is not None:
2347            params['projectKey'] = projectKey
2348        return self._fetch_pages(User, None, 'user/viewissue/search', startAt, maxResults, params)
2349
2350    # Versions
2351
2352    @translate_resource_args
2353    def create_version(self, name, project, description=None, releaseDate=None, startDate=None, archived=False,
2354                       released=False):
2355        """Create a version in a project and return a Resource for it.
2356
2357        :param name: name of the version to create
2358        :param project: key of the project to create the version in
2359        :param description: a description of the version
2360        :param releaseDate: the release date assigned to the version
2361        :param startDate: The start date for the version
2362        """
2363        data = {
2364            'name': name,
2365            'project': project,
2366            'archived': archived,
2367            'released': released}
2368        if description is not None:
2369            data['description'] = description
2370        if releaseDate is not None:
2371            data['releaseDate'] = releaseDate
2372        if startDate is not None:
2373            data['startDate'] = startDate
2374
2375        url = self._get_url('version')
2376        r = self._session.post(
2377            url, data=json.dumps(data))
2378
2379        time.sleep(1)
2380        version = Version(self._options, self._session, raw=json_loads(r))
2381        return version
2382
2383    def move_version(self, id, after=None, position=None):
2384        """Move a version within a project's ordered version list and return a new version Resource for it.
2385
2386        One, but not both, of ``after`` and ``position`` must be specified.
2387
2388        :param id: ID of the version to move
2389        :param after: the self attribute of a version to place the specified version after (that is, higher in the list)
2390        :param position: the absolute position to move this version to: must be one of ``First``, ``Last``,
2391            ``Earlier``, or ``Later``
2392        """
2393        data = {}
2394        if after is not None:
2395            data['after'] = after
2396        elif position is not None:
2397            data['position'] = position
2398
2399        url = self._get_url('version/' + id + '/move')
2400        r = self._session.post(
2401            url, data=json.dumps(data))
2402
2403        version = Version(self._options, self._session, raw=json_loads(r))
2404        return version
2405
2406    def version(self, id, expand=None):
2407        """Get a version Resource.
2408
2409        :param id: ID of the version to get
2410        :param expand: extra information to fetch inside each resource
2411        """
2412        version = Version(self._options, self._session)
2413        params = {}
2414        if expand is not None:
2415            params['expand'] = expand
2416        version.find(id, params=params)
2417        return version
2418
2419    def version_count_related_issues(self, id):
2420        """Get a dict of the counts of issues fixed and affected by a version.
2421
2422        :param id: the version to count issues for
2423        """
2424        r_json = self._get_json('version/' + id + '/relatedIssueCounts')
2425        del r_json['self']  # this isn't really an addressable resource
2426        return r_json
2427
2428    def version_count_unresolved_issues(self, id):
2429        """Get the number of unresolved issues for a version.
2430
2431        :param id: ID of the version to count issues for
2432        """
2433        return self._get_json('version/' + id + '/unresolvedIssueCount')['issuesUnresolvedCount']
2434
2435    # Session authentication
2436
2437    def session(self, auth=None):
2438        """Get a dict of the current authenticated user's session information."""
2439        url = '{server}{auth_url}'.format(**self._options)
2440
2441        if isinstance(self._session.auth, tuple) or auth:
2442            if not auth:
2443                auth = self._session.auth
2444            username, password = auth
2445            authentication_data = {'username': username, 'password': password}
2446            r = self._session.post(url, data=json.dumps(authentication_data))
2447        else:
2448            r = self._session.get(url)
2449
2450        user = User(self._options, self._session, json_loads(r))
2451        return user
2452
2453    def kill_session(self):
2454        """Destroy the session of the current authenticated user."""
2455        url = self._options['server'] + '/rest/auth/latest/session'
2456        return self._session.delete(url)
2457
2458    # Websudo
2459    def kill_websudo(self):
2460        """Destroy the user's current WebSudo session.
2461
2462        Works only for non-cloud deployments, for others does nothing.
2463        """
2464        if self.deploymentType != 'Cloud':
2465            url = self._options['server'] + '/rest/auth/1/websudo'
2466            return self._session.delete(url)
2467
2468    # Utilities
2469    def _create_http_basic_session(self, username, password, timeout=None):
2470        verify = self._options['verify']
2471        self._session = ResilientSession(timeout=timeout)
2472        self._session.verify = verify
2473        self._session.auth = (username, password)
2474        self._session.cert = self._options['client_cert']
2475
2476    def _create_oauth_session(self, oauth, timeout):
2477        verify = self._options['verify']
2478
2479        from oauthlib.oauth1 import SIGNATURE_RSA
2480        from requests_oauthlib import OAuth1
2481
2482        oauth = OAuth1(
2483            oauth['consumer_key'],
2484            rsa_key=oauth['key_cert'],
2485            signature_method=SIGNATURE_RSA,
2486            resource_owner_key=oauth['access_token'],
2487            resource_owner_secret=oauth['access_token_secret'])
2488        self._session = ResilientSession(timeout)
2489        self._session.verify = verify
2490        self._session.auth = oauth
2491
2492    def _create_kerberos_session(self, timeout, kerberos_options=None):
2493        verify = self._options['verify']
2494        if kerberos_options is None:
2495            kerberos_options = {}
2496
2497        from requests_kerberos import DISABLED
2498        from requests_kerberos import HTTPKerberosAuth
2499        from requests_kerberos import OPTIONAL
2500
2501        if kerberos_options.get('mutual_authentication', 'OPTIONAL') == 'OPTIONAL':
2502            mutual_authentication = OPTIONAL
2503        elif kerberos_options.get('mutual_authentication') == 'DISABLED':
2504            mutual_authentication = DISABLED
2505        else:
2506            raise ValueError("Unknown value for mutual_authentication: %s" %
2507                             kerberos_options['mutual_authentication'])
2508
2509        self._session = ResilientSession(timeout=timeout)
2510        self._session.verify = verify
2511        self._session.auth = HTTPKerberosAuth(mutual_authentication=mutual_authentication)
2512
2513    @staticmethod
2514    def _timestamp(dt=None):
2515        t = datetime.datetime.utcnow()
2516        if dt is not None:
2517            t += dt
2518        return calendar.timegm(t.timetuple())
2519
2520    def _create_jwt_session(self, jwt, timeout):
2521        try:
2522            jwt_auth = JWTAuth(jwt['secret'], alg='HS256')
2523        except NameError as e:
2524            logging.error("JWT authentication requires requests_jwt")
2525            raise e
2526        jwt_auth.set_header_format('JWT %s')
2527
2528        jwt_auth.add_field("iat", lambda req: JIRA._timestamp())
2529        jwt_auth.add_field("exp", lambda req: JIRA._timestamp(datetime.timedelta(minutes=3)))
2530        jwt_auth.add_field("qsh", QshGenerator(self._options['context_path']))
2531        for f in jwt['payload'].items():
2532            jwt_auth.add_field(f[0], f[1])
2533        self._session = ResilientSession(timeout=timeout)
2534        self._session.verify = self._options['verify']
2535        self._session.auth = jwt_auth
2536
2537    def _set_avatar(self, params, url, avatar):
2538        data = {
2539            'id': avatar}
2540        return self._session.put(url, params=params, data=json.dumps(data))
2541
2542    def _get_url(self, path, base=JIRA_BASE_URL):
2543        options = self._options.copy()
2544        options.update({'path': path})
2545        return base.format(**options)
2546
2547    def _get_json(self, path, params=None, base=JIRA_BASE_URL):
2548        url = self._get_url(path, base)
2549        r = self._session.get(url, params=params)
2550        try:
2551            r_json = json_loads(r)
2552        except ValueError as e:
2553            logging.error("%s\n%s" % (e, r.text))
2554            raise e
2555        return r_json
2556
2557    def _find_for_resource(self, resource_cls, ids, expand=None):
2558        resource = resource_cls(self._options, self._session)
2559        params = {}
2560        if expand is not None:
2561            params['expand'] = expand
2562        resource.find(id=ids, params=params)
2563        if not resource:
2564            raise JIRAError("Unable to find resource %s(%s)", resource_cls, ids)
2565        return resource
2566
2567    def _try_magic(self):
2568        try:
2569            import magic
2570            import weakref
2571        except ImportError:
2572            self._magic = None
2573        else:
2574            try:
2575                _magic = magic.Magic(flags=magic.MAGIC_MIME_TYPE)
2576
2577                def cleanup(x):
2578                    _magic.close()
2579                self._magic_weakref = weakref.ref(self, cleanup)
2580                self._magic = _magic
2581            except TypeError:
2582                self._magic = None
2583            except AttributeError:
2584                self._magic = None
2585
2586    def _get_mime_type(self, buff):
2587        if self._magic is not None:
2588            return self._magic.id_buffer(buff)
2589        else:
2590            try:
2591                return mimetypes.guess_type("f." + imghdr.what(0, buff))[0]
2592            except (IOError, TypeError):
2593                logging.warning("Couldn't detect content type of avatar image"
2594                                ". Specify the 'contentType' parameter explicitly.")
2595                return None
2596
2597    def rename_user(self, old_user, new_user):
2598        """Rename a JIRA user.
2599
2600        :param old_user: string with username login
2601        :param new_user: string with username login
2602        """
2603        if self._version > (6, 0, 0):
2604            url = self._options['server'] + '/rest/api/latest/user'
2605            payload = {
2606                "name": new_user}
2607            params = {
2608                'username': old_user}
2609
2610            # raw displayName
2611            logging.debug("renaming %s" % self.user(old_user).emailAddress)
2612
2613            r = self._session.put(url, params=params,
2614                                  data=json.dumps(payload))
2615            raise_on_error(r)
2616        else:
2617            raise NotImplementedError("Support for renaming users in Jira "
2618                                      "< 6.0.0 has been removed.")
2619
2620    def delete_user(self, username):
2621
2622        url = self._options['server'] + '/rest/api/latest/user/?username=%s' % username
2623
2624        r = self._session.delete(url)
2625        if 200 <= r.status_code <= 299:
2626            return True
2627        else:
2628            logging.error(r.status_code)
2629            return False
2630
2631    def deactivate_user(self, username):
2632        """Disable/deactivate the user."""
2633        if self.deploymentType == 'Cloud':
2634            url = self._options['server'] + '/admin/rest/um/1/user/deactivate?username=' + username
2635            self._options['headers']['Content-Type'] = 'application/json'
2636            userInfo = {}
2637        else:
2638            url = self._options['server'] + '/secure/admin/user/EditUser.jspa'
2639            self._options['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8'
2640            user = self.user(username)
2641            userInfo = {
2642                'inline': 'true',
2643                'decorator': 'dialog',
2644                'username': user.name,
2645                'fullName': user.displayName,
2646                'email': user.emailAddress,
2647                'editName': user.name
2648            }
2649        try:
2650            r = self._session.post(url, headers=self._options['headers'], data=userInfo)
2651            if r.status_code == 200:
2652                return True
2653            else:
2654                logging.warning(
2655                    'Got response from deactivating %s: %s' % (username, r.status_code))
2656                return r.status_code
2657        except Exception as e:
2658            print("Error Deactivating %s: %s" % (username, e))
2659
2660    def reindex(self, force=False, background=True):
2661        """Start jira re-indexing. Returns True if reindexing is in progress or not needed, or False.
2662
2663        If you call reindex() without any parameters it will perform a background reindex only if JIRA thinks it should do it.
2664
2665        :param force: reindex even if JIRA doesn't say this is needed, False by default.
2666        :param background: reindex in background, slower but does not impact the users, defaults to True.
2667        """
2668        # /secure/admin/IndexAdmin.jspa
2669        # /secure/admin/jira/IndexProgress.jspa?taskId=1
2670        if background:
2671            indexingStrategy = 'background'
2672        else:
2673            indexingStrategy = 'stoptheworld'
2674
2675        url = self._options['server'] + '/secure/admin/jira/IndexReIndex.jspa'
2676
2677        r = self._session.get(url, headers=self._options['headers'])
2678        if r.status_code == 503:
2679            # logging.warning("JIRA returned 503, this could mean that a full reindex is in progress.")
2680            return 503
2681
2682        if not r.text.find("To perform the re-index now, please go to the") and force is False:
2683            return True
2684
2685        if r.text.find('All issues are being re-indexed'):
2686            logging.warning("JIRA re-indexing is already running.")
2687            return True  # still reindexing is considered still a success
2688
2689        if r.text.find('To perform the re-index now, please go to the') or force:
2690            r = self._session.post(url, headers=self._options['headers'],
2691                                   params={"indexingStrategy": indexingStrategy, "reindex": "Re-Index"})
2692            if r.text.find('All issues are being re-indexed') != -1:
2693                return True
2694            else:
2695                logging.error("Failed to reindex jira, probably a bug.")
2696                return False
2697
2698    def backup(self, filename='backup.zip', attachments=False):
2699        """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished."""
2700        if self.deploymentType == 'Cloud':
2701            url = self._options['server'] + '/rest/backup/1/export/runbackup'
2702            payload = json.dumps({"cbAttachments": attachments})
2703            self._options['headers']['X-Requested-With'] = 'XMLHttpRequest'
2704        else:
2705            url = self._options['server'] + '/secure/admin/XmlBackup.jspa'
2706            payload = {'filename': filename}
2707        try:
2708            r = self._session.post(url, headers=self._options['headers'], data=payload)
2709            if r.status_code == 200:
2710                return True
2711            else:
2712                logging.warning(
2713                    'Got %s response from calling backup.' % r.status_code)
2714                return r.status_code
2715        except Exception as e:
2716            logging.error("I see %s", e)
2717
2718    def backup_progress(self):
2719        """Return status of cloud backup as a dict.
2720
2721        Is there a way to get progress for Server version?
2722        """
2723        epoch_time = int(time.time() * 1000)
2724        if self.deploymentType == 'Cloud':
2725            url = self._options['server'] + '/rest/obm/1.0/getprogress?_=%i' % epoch_time
2726        else:
2727            logging.warning(
2728                'This functionality is not available in Server version')
2729            return None
2730        r = self._session.get(
2731            url, headers=self._options['headers'])
2732        # This is weird.  I used to get xml, but now I'm getting json
2733        try:
2734            return json.loads(r.text)
2735        except Exception:
2736            import defusedxml.ElementTree as etree
2737
2738            progress = {}
2739            try:
2740                root = etree.fromstring(r.text)
2741            except etree.ParseError as pe:
2742                logging.warning('Unable to find backup info.  You probably need to initiate a new backup. %s' % pe)
2743                return None
2744            for k in root.keys():
2745                progress[k] = root.get(k)
2746            return progress
2747
2748    def backup_complete(self):
2749        """Return boolean based on 'alternativePercentage' and 'size' returned from backup_progress (cloud only)."""
2750        if self.deploymentType != 'Cloud':
2751            logging.warning(
2752                'This functionality is not available in Server version')
2753            return None
2754        status = self.backup_progress()
2755        perc_complete = int(re.search(r"\s([0-9]*)\s",
2756                                      status['alternativePercentage']).group(1))
2757        file_size = int(status['size'])
2758        return perc_complete >= 100 and file_size > 0
2759
2760    def backup_download(self, filename=None):
2761        """Download backup file from WebDAV (cloud only)."""
2762        if self.deploymentType != 'Cloud':
2763            logging.warning(
2764                'This functionality is not available in Server version')
2765            return None
2766        remote_file = self.backup_progress()['fileName']
2767        local_file = filename or remote_file
2768        url = self._options['server'] + '/webdav/backupmanager/' + remote_file
2769        try:
2770            logging.debug('Writing file to %s' % local_file)
2771            with open(local_file, 'wb') as file:
2772                try:
2773                    resp = self._session.get(url, headers=self._options['headers'], stream=True)
2774                except Exception:
2775                    raise JIRAError()
2776                if not resp.ok:
2777                    logging.error("Something went wrong with download: %s" % resp.text)
2778                    raise JIRAError(resp.text)
2779                for block in resp.iter_content(1024):
2780                    file.write(block)
2781        except JIRAError as je:
2782            logging.error('Unable to access remote backup file: %s' % je)
2783        except IOError as ioe:
2784            logging.error(ioe)
2785        return None
2786
2787    def current_user(self):
2788        if not hasattr(self, '_serverInfo') or 'username' not in self._serverInfo:
2789
2790            url = self._get_url('serverInfo')
2791            r = self._session.get(url, headers=self._options['headers'])
2792
2793            r_json = json_loads(r)
2794            if 'x-ausername' in r.headers:
2795                r_json['username'] = r.headers['x-ausername']
2796            else:
2797                r_json['username'] = None
2798            self._serverInfo = r_json
2799            # del r_json['self']  # this isn't really an addressable resource
2800        return self._serverInfo['username']
2801
2802    def delete_project(self, pid):
2803        """Delete project from Jira.
2804
2805        :param str pid:     JIRA projectID or Project or slug
2806        :returns bool:      True if project was deleted
2807        :raises JIRAError:  If project not found or not enough permissions
2808        :raises ValueError: If pid parameter is not Project, slug or ProjectID
2809        """
2810        # allows us to call it with Project objects
2811        if hasattr(pid, 'id'):
2812            pid = pid.id
2813
2814        # Check if pid is a number - then we assume that it is
2815        # projectID
2816        try:
2817            str(int(pid)) == pid
2818        except Exception as e:
2819            # pid looks like a slug, lets verify that
2820            r_json = self._get_json('project')
2821            for e in r_json:
2822                if e['key'] == pid or e['name'] == pid:
2823                    pid = e['id']
2824                    break
2825            else:
2826                # pid is not a Project
2827                # not a projectID and not a slug - we raise error here
2828                raise ValueError('Parameter pid="%s" is not a Project, '
2829                                 'projectID or slug' % pid)
2830
2831        uri = '/rest/api/2/project/%s' % pid
2832        url = self._options['server'] + uri
2833        try:
2834            r = self._session.delete(
2835                url, headers={'Content-Type': 'application/json'}
2836            )
2837        except JIRAError as je:
2838            if '403' in str(je):
2839                raise JIRAError('Not enough permissions to delete project')
2840            if '404' in str(je):
2841                raise JIRAError('Project not found in Jira')
2842            raise je
2843
2844        if r.status_code == 204:
2845            return True
2846
2847    def _gain_sudo_session(self, options, destination):
2848        url = self._options['server'] + '/secure/admin/WebSudoAuthenticate.jspa'
2849
2850        if not self._session.auth:
2851            self._session.auth = get_netrc_auth(url)
2852
2853        payload = {
2854            'webSudoPassword': self._session.auth[1],
2855            'webSudoDestination': destination,
2856            'webSudoIsPost': 'true'}
2857
2858        payload.update(options)
2859
2860        return self._session.post(
2861            url, headers=CaseInsensitiveDict({'content-type': 'application/x-www-form-urlencoded'}), data=payload)
2862
2863    def create_project(self, key, name=None, assignee=None, type="Software", template_name=None):
2864        """Key is mandatory and has to match JIRA project key requirements, usually only 2-10 uppercase characters.
2865
2866        If name is not specified it will use the key value.
2867        If assignee is not specified it will use current user.
2868        Parameter template_name is used to create a project based on one of the existing project templates.
2869        If template_name is not specified, then it should use one of the default values.
2870        The returned value should evaluate to False if it fails otherwise it will be the new project id.
2871        """
2872        if assignee is None:
2873            assignee = self.current_user()
2874        if name is None:
2875            name = key
2876        url = self._options['server'] + \
2877            '/rest/project-templates/latest/templates'
2878
2879        r = self._session.get(url)
2880        j = json_loads(r)
2881
2882        possible_templates = ['JIRA Classic', 'JIRA Default Schemes', 'Basic software development']
2883
2884        if template_name is not None:
2885            possible_templates = [template_name]
2886
2887        # https://confluence.atlassian.com/jirakb/creating-a-project-via-rest-based-on-jira-default-schemes-744325852.html
2888        template_key = 'com.atlassian.jira-legacy-project-templates:jira-blank-item'
2889        templates = []
2890        for template in _get_template_list(j):
2891            templates.append(template['name'])
2892            if template['name'] in possible_templates:
2893                template_key = template['projectTemplateModuleCompleteKey']
2894                break
2895
2896        payload = {'name': name,
2897                   'key': key,
2898                   'keyEdited': 'false',
2899                   # 'projectTemplate': 'com.atlassian.jira-core-project-templates:jira-issuetracking',
2900                   # 'permissionScheme': '',
2901                   'projectTemplateWebItemKey': template_key,
2902                   'projectTemplateModuleKey': template_key,
2903                   'lead': assignee,
2904                   # 'assigneeType': '2',
2905                   }
2906
2907        if self._version[0] > 6:
2908            # JIRA versions before 7 will throw an error if we specify type parameter
2909            payload['type'] = type
2910
2911        headers = CaseInsensitiveDict(
2912            {'Content-Type': 'application/x-www-form-urlencoded'})
2913
2914        r = self._session.post(url, data=payload, headers=headers)
2915
2916        if r.status_code == 200:
2917            r_json = json_loads(r)
2918            return r_json
2919
2920        f = tempfile.NamedTemporaryFile(
2921            suffix='.html', prefix='python-jira-error-create-project-', delete=False)
2922        f.write(r.text)
2923
2924        if self.logging:
2925            logging.error(
2926                "Unexpected result while running create project. Server response saved in %s for further investigation [HTTP response=%s]." % (
2927                    f.name, r.status_code))
2928        return False
2929
2930    def add_user(self, username, email, directoryId=1, password=None,
2931                 fullname=None, notify=False, active=True, ignore_existing=False, application_keys=None):
2932        """Create a new JIRA user.
2933
2934        :param username: the username of the new user
2935        :type username: ``str``
2936        :param email: email address of the new user
2937        :type email: ``str``
2938        :param directoryId: the directory ID the new user should be a part of
2939        :type directoryId: ``int``
2940        :param password: Optional, the password for the new user
2941        :type password: ``str``
2942        :param fullname: Optional, the full name of the new user
2943        :type fullname: ``str``
2944        :param notify: Whether or not to send a notification to the new user
2945        :type notify: ``bool``
2946        :param active: Whether or not to make the new user active upon creation
2947        :type active: ``bool``
2948        :param applicationKeys: Keys of products user should have access to
2949        :type applicationKeys: ``list``
2950        """
2951        if not fullname:
2952            fullname = username
2953        # TODO(ssbarnea): default the directoryID to the first directory in jira instead
2954        # of 1 which is the internal one.
2955        url = self._options['server'] + '/rest/api/latest/user'
2956
2957        # implementation based on
2958        # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173
2959        x = OrderedDict()
2960
2961        x['displayName'] = fullname
2962        x['emailAddress'] = email
2963        x['name'] = username
2964        if password:
2965            x['password'] = password
2966        if notify:
2967            x['notification'] = 'True'
2968        if application_keys is not None:
2969            x['applicationKeys'] = application_keys
2970
2971        payload = json.dumps(x)
2972        try:
2973            self._session.post(url, data=payload)
2974        except JIRAError as e:
2975            err = e.response.json()['errors']
2976            if 'username' in err and err['username'] == 'A user with that username already exists.' and ignore_existing:
2977                return True
2978            raise e
2979        return True
2980
2981    def add_user_to_group(self, username, group):
2982        """Add a user to an existing group.
2983
2984        :param username: Username that will be added to specified group.
2985        :param group: Group that the user will be added to.
2986        :return: json response from Jira server for success or a value that evaluates as False in case of failure.
2987        """
2988        url = self._options['server'] + '/rest/api/latest/group/user'
2989        x = {'groupname': group}
2990        y = {'name': username}
2991
2992        payload = json.dumps(y)
2993
2994        r = json_loads(self._session.post(url, params=x, data=payload))
2995        if 'name' not in r or r['name'] != group:
2996            return False
2997        else:
2998            return r
2999
3000    def remove_user_from_group(self, username, groupname):
3001        """Remove a user from a group.
3002
3003        :param username: The user to remove from the group.
3004        :param groupname: The group that the user will be removed from.
3005        """
3006        url = self._options['server'] + '/rest/api/latest/group/user'
3007        x = {'groupname': groupname,
3008             'username': username}
3009
3010        self._session.delete(url, params=x)
3011
3012        return True
3013
3014    # Experimental
3015    # Experimental support for iDalko Grid, expect API to change as it's using private APIs currently
3016    # https://support.idalko.com/browse/IGRID-1017
3017    def get_igrid(self, issueid, customfield, schemeid):
3018        url = self._options['server'] + '/rest/idalko-igrid/1.0/datagrid/data'
3019        if str(customfield).isdigit():
3020            customfield = "customfield_%s" % customfield
3021        params = {
3022            '_issueId': issueid,
3023            '_fieldId': customfield,
3024            '_confSchemeId': schemeid}
3025        r = self._session.get(
3026            url, headers=self._options['headers'], params=params)
3027        return json_loads(r)
3028
3029    # Jira Agile specific methods (GreenHopper)
3030    """
3031    Define the functions that interact with GreenHopper.
3032    """
3033
3034    @translate_resource_args
3035    def boards(self, startAt=0, maxResults=50, type=None, name=None):
3036        """Get a list of board resources.
3037
3038        :param startAt: The starting index of the returned boards. Base index: 0.
3039        :param maxResults: The maximum number of boards to return per page. Default: 50
3040        :param type: Filters results to boards of the specified type. Valid values: scrum, kanban.
3041        :param name: Filters results to boards that match or partially match the specified name.
3042        :rtype: ResultList[Board]
3043
3044        When old GreenHopper private API is used, paging is not enabled and all parameters are ignored.
3045        """
3046        params = {}
3047        if type:
3048            params['type'] = type
3049        if name:
3050            params['name'] = name
3051
3052        if self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH:
3053            # Old, private API did not support pagination, all records were present in response,
3054            #   and no parameters were supported.
3055            if startAt or maxResults or params:
3056                warnings.warn('Old private GreenHopper API is used, all parameters will be ignored.', Warning)
3057
3058            r_json = self._get_json('rapidviews/list', base=self.AGILE_BASE_URL)
3059            boards = [Board(self._options, self._session, raw_boards_json) for raw_boards_json in r_json['views']]
3060            return ResultList(boards, 0, len(boards), len(boards), True)
3061        else:
3062            return self._fetch_pages(Board, 'values', 'board', startAt, maxResults, params, base=self.AGILE_BASE_URL)
3063
3064    @translate_resource_args
3065    def sprints(self, board_id, extended=False, startAt=0, maxResults=50, state=None):
3066        """Get a list of sprint GreenHopperResources.
3067
3068        :param board_id: the board to get sprints from
3069        :param extended: Used only by old GreenHopper API to fetch additional information like
3070            startDate, endDate, completeDate, much slower because it requires an additional requests for each sprint.
3071            New JIRA Agile API always returns this information without a need for additional requests.
3072        :param startAt: the index of the first sprint to return (0 based)
3073        :param maxResults: the maximum number of sprints to return
3074        :param state: Filters results to sprints in specified states. Valid values: `future`, `active`, `closed`.
3075            You can define multiple states separated by commas
3076
3077        :type board_id: int
3078        :type extended: bool
3079        :type startAt: int
3080        :type maxResults: int
3081        :type state: str
3082
3083        :rtype: list of :class:`~jira.resources.Sprint`
3084        :return: (content depends on API version, but always contains id, name, state, startDate and endDate)
3085            When old GreenHopper private API is used, paging is not enabled,
3086            and `startAt`, `maxResults` and `state` parameters are ignored.
3087        """
3088        params = {}
3089        if state:
3090            params['state'] = state
3091
3092        if self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH:
3093            r_json = self._get_json('sprintquery/%s?includeHistoricSprints=true&includeFutureSprints=true' % board_id,
3094                                    base=self.AGILE_BASE_URL)
3095
3096            if params:
3097                warnings.warn('Old private GreenHopper API is used, parameters %s will be ignored.' % params, Warning)
3098
3099            if extended:
3100                sprints = [Sprint(self._options, self._session, self.sprint_info(None, raw_sprints_json['id']))
3101                           for raw_sprints_json in r_json['sprints']]
3102            else:
3103                sprints = [Sprint(self._options, self._session, raw_sprints_json)
3104                           for raw_sprints_json in r_json['sprints']]
3105
3106            return ResultList(sprints, 0, len(sprints), len(sprints), True)
3107        else:
3108            return self._fetch_pages(Sprint, 'values', 'board/%s/sprint' % board_id, startAt, maxResults, params,
3109                                     self.AGILE_BASE_URL)
3110
3111    def sprints_by_name(self, id, extended=False):
3112        sprints = {}
3113        for s in self.sprints(id, extended=extended):
3114            if s.name not in sprints:
3115                sprints[s.name] = s.raw
3116            else:
3117                raise Exception
3118        return sprints
3119
3120    def update_sprint(self, id, name=None, startDate=None, endDate=None, state=None):
3121        payload = {}
3122        if name:
3123            payload['name'] = name
3124        if startDate:
3125            payload['startDate'] = startDate
3126        if endDate:
3127            payload['endDate'] = endDate
3128        if state:
3129            if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH:
3130                raise NotImplementedError('Public JIRA API does not support state update')
3131            payload['state'] = state
3132
3133        url = self._get_url('sprint/%s' % id, base=self.AGILE_BASE_URL)
3134        r = self._session.put(
3135            url, data=json.dumps(payload))
3136
3137        return json_loads(r)
3138
3139    def incompletedIssuesEstimateSum(self, board_id, sprint_id):
3140        """Return the total incompleted points this sprint."""
3141        return self._get_json('rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s' % (board_id, sprint_id),
3142                              base=self.AGILE_BASE_URL)['contents']['incompletedIssuesEstimateSum']['value']
3143
3144    def removed_issues(self, board_id, sprint_id):
3145        """Return the completed issues for the sprint."""
3146        r_json = self._get_json('rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s' % (board_id, sprint_id),
3147                                base=self.AGILE_BASE_URL)
3148        issues = [Issue(self._options, self._session, raw_issues_json) for raw_issues_json in
3149                  r_json['contents']['puntedIssues']]
3150
3151        return issues
3152
3153    def removedIssuesEstimateSum(self, board_id, sprint_id):
3154        """Return the total incompleted points this sprint."""
3155        return self._get_json('rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s' % (board_id, sprint_id),
3156                              base=self.AGILE_BASE_URL)['contents']['puntedIssuesEstimateSum']['value']
3157
3158    # TODO(ssbarnea): remove sprint_info() method, sprint() method suit the convention more
3159    def sprint_info(self, board_id, sprint_id):
3160        """Return the information about a sprint.
3161
3162        :param board_id: the board retrieving issues from. Deprecated and ignored.
3163        :param sprint_id: the sprint retrieving issues from
3164        """
3165        sprint = Sprint(self._options, self._session)
3166        sprint.find(sprint_id)
3167        return sprint.raw
3168
3169    def sprint(self, id):
3170        """Return the information about a sprint.
3171
3172        :param sprint_id: the sprint retrieving issues from
3173
3174        :type sprint_id: int
3175
3176        :rtype: :class:`~jira.resources.Sprint`
3177        """
3178        sprint = Sprint(self._options, self._session)
3179        sprint.find(id)
3180        return sprint
3181
3182    # TODO(ssbarnea): remove this as we do have Board.delete()
3183    def delete_board(self, id):
3184        """Delete an agile board."""
3185        board = Board(self._options, self._session, raw={'id': id})
3186        board.delete()
3187
3188    def create_board(self, name, project_ids, preset="scrum",
3189                     location_type='user', location_id=None):
3190        """Create a new board for the ``project_ids``.
3191
3192        :param name: name of the board
3193        :param project_ids: the projects to create the board in
3194        :param preset: what preset to use for this board
3195        :type preset: 'kanban', 'scrum', 'diy'
3196        :param location_type: the location type. Available in cloud.
3197        :type location_type: 'user', 'project'
3198        :param location_id: the id of project that the board should be
3199            located under. Omit this for a 'user' location_type. Available in cloud.
3200        """
3201        if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH:
3202            raise NotImplementedError('JIRA Agile Public API does not support this request')
3203
3204        payload = {}
3205        if isinstance(project_ids, string_types):
3206            ids = []
3207            for p in project_ids.split(','):
3208                ids.append(self.project(p).id)
3209            project_ids = ','.join(ids)
3210
3211        payload['name'] = name
3212        if isinstance(project_ids, string_types):
3213            project_ids = project_ids.split(',')
3214        payload['projectIds'] = project_ids
3215        payload['preset'] = preset
3216        if self.deploymentType == 'Cloud':
3217            payload['locationType'] = location_type
3218            payload['locationId'] = location_id
3219        url = self._get_url(
3220            'rapidview/create/presets', base=self.AGILE_BASE_URL)
3221        r = self._session.post(
3222            url, data=json.dumps(payload))
3223
3224        raw_issue_json = json_loads(r)
3225        return Board(self._options, self._session, raw=raw_issue_json)
3226
3227    def create_sprint(self, name, board_id, startDate=None, endDate=None):
3228        """Create a new sprint for the ``board_id``.
3229
3230        :param name: name of the sprint
3231        :param board_id: the board to add the sprint to
3232        """
3233        payload = {'name': name}
3234        if startDate:
3235            payload["startDate"] = startDate
3236        if endDate:
3237            payload["endDate"] = endDate
3238
3239        if self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH:
3240            url = self._get_url('sprint/%s' % board_id, base=self.AGILE_BASE_URL)
3241            r = self._session.post(url)
3242            raw_issue_json = json_loads(r)
3243            """ now r contains something like:
3244            {
3245                  "id": 742,
3246                  "name": "Sprint 89",
3247                  "state": "FUTURE",
3248                  "linkedPagesCount": 0,
3249                  "startDate": "None",
3250                  "endDate": "None",
3251                  "completeDate": "None",
3252                  "remoteLinks": []
3253            }"""
3254
3255            url = self._get_url(
3256                'sprint/%s' % raw_issue_json['id'], base=self.AGILE_BASE_URL)
3257            r = self._session.put(
3258                url, data=json.dumps(payload))
3259            raw_issue_json = json_loads(r)
3260        else:
3261            url = self._get_url('sprint', base=self.AGILE_BASE_URL)
3262            payload['originBoardId'] = board_id
3263            r = self._session.post(url, data=json.dumps(payload))
3264            raw_issue_json = json_loads(r)
3265
3266        return Sprint(self._options, self._session, raw=raw_issue_json)
3267
3268    def add_issues_to_sprint(self, sprint_id, issue_keys):
3269        """Add the issues in ``issue_keys`` to the ``sprint_id``.
3270
3271        The sprint must be started but not completed.
3272
3273        If a sprint was completed, then have to also edit the history of the
3274        issue so that it was added to the sprint before it was completed,
3275        preferably before it started. A completed sprint's issues also all have
3276        a resolution set before the completion date.
3277
3278        If a sprint was not started, then have to edit the marker and copy the
3279        rank of each issue too.
3280
3281        :param sprint_id: the sprint to add issues to
3282        :param issue_keys: the issues to add to the sprint
3283
3284        :type sprint_id: int
3285        :type issue_keys: list of str
3286        """
3287        if self._options['agile_rest_path'] == GreenHopperResource.AGILE_BASE_REST_PATH:
3288            url = self._get_url('sprint/%s/issue' % sprint_id, base=self.AGILE_BASE_URL)
3289            payload = {'issues': issue_keys}
3290            try:
3291                self._session.post(url, data=json.dumps(payload))
3292            except JIRAError as e:
3293                if e.status_code == 404:
3294                    warnings.warn('Status code 404 may mean, that too old JIRA Agile version is installed.'
3295                                  ' At least version 6.7.10 is required.')
3296                raise
3297        elif self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH:
3298            # In old, private API the function does not exist anymore and we need to use
3299            # issue.update() to perform this operation
3300            # Workaround based on https://answers.atlassian.com/questions/277651/jira-agile-rest-api-example
3301
3302            sprint_field_id = self._get_sprint_field_id()
3303
3304            data = {'idOrKeys': issue_keys, 'customFieldId': sprint_field_id,
3305                    'sprintId': sprint_id, 'addToBacklog': False}
3306            url = self._get_url('sprint/rank', base=self.AGILE_BASE_URL)
3307            return self._session.put(url, data=json.dumps(data))
3308        else:
3309            raise NotImplementedError('No API for adding issues to sprint for agile_rest_path="%s"' %
3310                                      self._options['agile_rest_path'])
3311
3312    def add_issues_to_epic(self, epic_id, issue_keys, ignore_epics=True):
3313        """Add the issues in ``issue_keys`` to the ``epic_id``.
3314
3315        :param epic_id: the epic to add issues to
3316        :param issue_keys: the issues to add to the epic
3317        :param ignore_epics: ignore any issues listed in ``issue_keys`` that are epics
3318        """
3319        if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH:
3320            # TODO(ssbarnea): simulate functionality using issue.update()?
3321            raise NotImplementedError('JIRA Agile Public API does not support this request')
3322
3323        data = {}
3324        data['issueKeys'] = issue_keys
3325        data['ignoreEpics'] = ignore_epics
3326        url = self._get_url('epics/%s/add' %
3327                            epic_id, base=self.AGILE_BASE_URL)
3328        return self._session.put(
3329            url, data=json.dumps(data))
3330
3331    # TODO(ssbarnea): Both GreenHopper and new JIRA Agile API support moving more than one issue.
3332    def rank(self, issue, next_issue):
3333        """Rank an issue before another using the default Ranking field, the one named 'Rank'.
3334
3335        :param issue: issue key of the issue to be ranked before the second one.
3336        :param next_issue: issue key of the second issue.
3337        """
3338        if not self._rank:
3339            for field in self.fields():
3340                if field['name'] == 'Rank':
3341                    if field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-lexo-rank":
3342                        self._rank = field['schema']['customId']
3343                        break
3344                    elif field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-global-rank":
3345                        # Obsolete since JIRA v6.3.13.1
3346                        self._rank = field['schema']['customId']
3347
3348        if self._options['agile_rest_path'] == GreenHopperResource.AGILE_BASE_REST_PATH:
3349            url = self._get_url('issue/rank', base=self.AGILE_BASE_URL)
3350            payload = {'issues': [issue], 'rankBeforeIssue': next_issue, 'rankCustomFieldId': self._rank}
3351            try:
3352                return self._session.put(url, data=json.dumps(payload))
3353            except JIRAError as e:
3354                if e.status_code == 404:
3355                    warnings.warn('Status code 404 may mean, that too old JIRA Agile version is installed.'
3356                                  ' At least version 6.7.10 is required.')
3357                raise
3358        elif self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH:
3359            data = {
3360                "issueKeys": [issue], "rankBeforeKey": next_issue, "customFieldId": self._rank}
3361            url = self._get_url('rank', base=self.AGILE_BASE_URL)
3362            return self._session.put(url, data=json.dumps(data))
3363        else:
3364            raise NotImplementedError('No API for ranking issues for agile_rest_path="%s"' %
3365                                      self._options['agile_rest_path'])
3366
3367    def move_to_backlog(self, issue_keys):
3368        """Move issues in ``issue_keys`` to the backlog, removing them from all sprints that have not been completed.
3369
3370        :param issue_keys: the issues to move to the backlog
3371        """
3372        if self._options['agile_rest_path'] == GreenHopperResource.AGILE_BASE_REST_PATH:
3373            url = self._get_url('backlog/issue', base=self.AGILE_BASE_URL)
3374            payload = {'issues': issue_keys}
3375            try:
3376                self._session.post(url, data=json.dumps(payload))
3377            except JIRAError as e:
3378                if e.status_code == 404:
3379                    warnings.warn('Status code 404 may mean, that too old JIRA Agile version is installed.'
3380                                  ' At least version 6.7.10 is required.')
3381                raise
3382        elif self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH:
3383            # In old, private API the function does not exist anymore and we need to use
3384            # issue.update() to perform this operation
3385            # Workaround based on https://answers.atlassian.com/questions/277651/jira-agile-rest-api-example
3386
3387            sprint_field_id = self._get_sprint_field_id()
3388
3389            data = {'idOrKeys': issue_keys, 'customFieldId': sprint_field_id,
3390                    'addToBacklog': True}
3391            url = self._get_url('sprint/rank', base=self.AGILE_BASE_URL)
3392            return self._session.put(url, data=json.dumps(data))
3393        else:
3394            raise NotImplementedError('No API for moving issues to backlog for agile_rest_path="%s"' %
3395                                      self._options['agile_rest_path'])
3396
3397
3398class GreenHopper(JIRA):
3399
3400    def __init__(self, options=None, basic_auth=None, oauth=None, async_=None):
3401        warnings.warn(
3402            "GreenHopper() class is deprecated, just use JIRA() instead.", DeprecationWarning)
3403        JIRA.__init__(
3404            self, options=options, basic_auth=basic_auth, oauth=oauth, async_=async_)
3405