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