PageRenderTime 103ms CodeModel.GetById 21ms RepoModel.GetById 1ms app.codeStats 1ms

/jira/client.py

https://bitbucket.org/chiemseesurfer/jira-python
Python | 2176 lines | 2039 code | 39 blank | 98 comment | 40 complexity | d39fa675f3324e4420cac15c776e2a9a MD5 | raw file
Possible License(s): BSD-3-Clause

Large files files are truncated, but you can click here to view the full file

  1. """
  2. This module implements a friendly (well, friendlier) interface between the raw JSON
  3. responses from JIRA and the Resource/dict abstractions provided by this library. Users
  4. will construct a JIRA object as described below. Full API documentation can be found
  5. at: https://jira-python.readthedocs.org/en/latest/
  6. """
  7. from __future__ import print_function
  8. from functools import wraps
  9. import imghdr
  10. import mimetypes
  11. import copy
  12. import os
  13. import re
  14. import sys
  15. import string
  16. import tempfile
  17. import logging
  18. import requests
  19. import json
  20. from six import string_types
  21. from six.moves.html_parser import HTMLParser
  22. from six import print_ as print
  23. from requests_oauthlib import OAuth1
  24. from oauthlib.oauth1 import SIGNATURE_RSA
  25. from jira.exceptions import raise_on_error
  26. # JIRA specific resources
  27. from jira.resources import Resource, Issue, Comment, Project, Attachment, Component, Dashboard, Filter, Votes, Watchers, Worklog, IssueLink, IssueLinkType, IssueType, Priority, Version, Role, Resolution, SecurityLevel, Status, User, CustomFieldOption, RemoteLink
  28. # GreenHopper specific resources
  29. from jira.resources import GreenHopperResource, Board, Sprint
  30. from jira.resilientsession import ResilientSession
  31. try:
  32. from random import SystemRandom
  33. random = SystemRandom()
  34. except ImportError:
  35. import random
  36. if 'pydevd' not in sys.modules:
  37. try:
  38. import grequests
  39. except ImportError:
  40. pass
  41. except NotImplementedError:
  42. pass
  43. def translate_resource_args(func):
  44. """
  45. Decorator that converts Issue and Project resources to their keys when used as arguments.
  46. """
  47. @wraps(func)
  48. def wrapper(*args, **kwargs):
  49. arg_list = []
  50. for arg in args:
  51. if isinstance(arg, (Issue, Project)):
  52. arg_list.append(arg.key)
  53. else:
  54. arg_list.append(arg)
  55. result = func(*arg_list, **kwargs)
  56. return result
  57. return wrapper
  58. class ResultList(list):
  59. def __init__(self, iterable=None, _total=None):
  60. if iterable is not None:
  61. list.__init__(self, iterable)
  62. else:
  63. list.__init__(self)
  64. self.total = _total if _total is not None else len(self)
  65. class JIRA(object):
  66. """
  67. User interface to JIRA.
  68. Clients interact with JIRA by constructing an instance of this object and calling its methods. For addressable
  69. resources in JIRA -- those with "self" links -- an appropriate subclass of :py:class:`Resource` will be returned
  70. with customized ``update()`` and ``delete()`` methods, along with attribute access to fields. This means that calls
  71. of the form ``issue.fields.summary`` will be resolved into the proper lookups to return the JSON value at that
  72. mapping. Methods that do not return resources will return a dict constructed from the JSON response or a scalar
  73. value; see each method's documentation for details on what that method returns.
  74. """
  75. DEFAULT_OPTIONS = {
  76. "server": "http://localhost:2990/jira",
  77. "rest_path": "api",
  78. "rest_api_version": "2",
  79. "verify": True,
  80. "resilient": False,
  81. "headers": {
  82. 'X-Atlassian-Token': 'nocheck',
  83. #'Cache-Control': 'no-cache',
  84. #'Pragma': 'no-cache',
  85. #'Expires': 'Thu, 01 Jan 1970 00:00:00 GMT'
  86. }
  87. }
  88. JIRA_BASE_URL = '{server}/rest/api/{rest_api_version}/{path}'
  89. def __init__(self, options=None, basic_auth=None, oauth=None, validate=None):
  90. """
  91. Construct a JIRA client instance.
  92. Without any arguments, this client will connect anonymously to the JIRA instance
  93. started by the Atlassian Plugin SDK from one of the 'atlas-run', ``atlas-debug``,
  94. or ``atlas-run-standalone`` commands. By default, this instance runs at
  95. ``http://localhost:2990/jira``. The ``options`` argument can be used to set the JIRA instance to use.
  96. Authentication is handled with the ``basic_auth`` argument. If authentication is supplied (and is
  97. accepted by JIRA), the client will remember it for subsequent requests.
  98. For quick command line access to a server, see the ``jirashell`` script included with this distribution.
  99. :param options: Specify the server and properties this client will use. Use a dict with any
  100. of the following properties:
  101. * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``.
  102. * rest_path -- the root REST path to use. Defaults to ``api``, where the JIRA REST resources live.
  103. * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``.
  104. * verify -- Verify SSL certs. Defaults to ``True``.
  105. * resilient -- If it should just retry recoverable errors. Defaults to `False`.
  106. :param basic_auth: A tuple of username and password to use when establishing a session via HTTP BASIC
  107. authentication.
  108. :param oauth: A dict of properties for OAuth authentication. The following properties are required:
  109. * access_token -- OAuth access token for the user
  110. * access_token_secret -- OAuth access token secret to sign with the key
  111. * consumer_key -- key of the OAuth application link defined in JIRA
  112. * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to
  113. JIRA in the OAuth application link)
  114. :param validate: If true it will validate your credentials first. Remember that if you are accesing JIRA
  115. as anononymous it will fail to instanciate.
  116. """
  117. if options is None:
  118. options = {}
  119. self._options = copy.copy(JIRA.DEFAULT_OPTIONS)
  120. self._options.update(options)
  121. # Rip off trailing slash since all urls depend on that
  122. if self._options['server'].endswith('/'):
  123. self._options['server'] = self._options['server'][:-1]
  124. self._try_magic()
  125. if oauth:
  126. self._create_oauth_session(oauth)
  127. elif basic_auth:
  128. self._create_http_basic_session(*basic_auth)
  129. else:
  130. verify = self._options['verify']
  131. if self._options['resilient']:
  132. self._session = ResilientSession()
  133. else:
  134. self._session = requests.Session()
  135. self._session.verify = verify
  136. self._session.headers.update(self._options['headers'])
  137. if validate:
  138. self.session() # This will raise an Exception if you are not allowed to login. It's better to fail faster than later.
  139. # We need version in order to know what API calls are available or not
  140. self.version = tuple(self.server_info()['versionNumbers'])
  141. # Information about this client
  142. def client_info(self):
  143. """Get the server this client is connected to."""
  144. return self._options['server']
  145. # Universal resource loading
  146. def find(self, resource_format, ids=None):
  147. """
  148. Get a Resource object for any addressable resource on the server.
  149. This method is a universal resource locator for any RESTful resource in JIRA. The
  150. argument ``resource_format`` is a string of the form ``resource``, ``resource/{0}``,
  151. ``resource/{0}/sub``, ``resource/{0}/sub/{1}``, etc. The format placeholders will be
  152. populated from the ``ids`` argument if present. The existing authentication session
  153. will be used.
  154. The return value is an untyped Resource object, which will not support specialized
  155. :py:meth:`.Resource.update` or :py:meth:`.Resource.delete` behavior. Moreover, it will
  156. not know to return an issue Resource if the client uses the resource issue path. For this
  157. reason, it is intended to support resources that are not included in the standard
  158. Atlassian REST API.
  159. :param resource_format: the subpath to the resource string
  160. :param ids: values to substitute in the ``resource_format`` string
  161. :type ids: tuple or None
  162. """
  163. resource = Resource(resource_format, self._options, self._session)
  164. resource.find(ids)
  165. return resource
  166. def async_do(self, size=10):
  167. """
  168. This will execute all async jobs and wait for them to finish. By default it will run on 10 threads.
  169. size: number of threads to run on.
  170. :return:
  171. """
  172. if hasattr(self._session, '_async_jobs'):
  173. grequests.map(self._session._async_jobs, size=size)
  174. # Application properties
  175. # non-resource
  176. def application_properties(self, key=None):
  177. """
  178. Return the mutable server application properties.
  179. :param key: the single property to return a value for
  180. """
  181. params = {}
  182. if key is not None:
  183. params['key'] = key
  184. return self._get_json('application-properties', params=params)
  185. def set_application_property(self, key, value):
  186. """
  187. Set the application property.
  188. :param key: key of the property to set
  189. :param value: value to assign to the property
  190. """
  191. url = self._options['server'] + '/rest/api/2/application-properties/' + key
  192. payload = {
  193. 'id': key,
  194. 'value': value
  195. }
  196. r = self._session.put(url, headers={'content-type': 'application/json'}, data=json.dumps(payload))
  197. raise_on_error(r)
  198. # ApplicationLinks
  199. def applicationlinks(self):
  200. """
  201. List of application links
  202. :return: json
  203. """
  204. url = self._options['server'] + '/rest/applinks/1.0/applicationlink'
  205. headers = copy.deepcopy(self._options['headers'])
  206. headers['content-type'] = 'application/json;charset=UTF-8'
  207. r = self._session.get(url, headers=headers)
  208. raise_on_error(r)
  209. r_json = json.loads(r.text)
  210. return r_json
  211. # Attachments
  212. def attachment(self, id):
  213. """Get an attachment Resource from the server for the specified ID."""
  214. return self._find_for_resource(Attachment, id)
  215. # non-resource
  216. def attachment_meta(self):
  217. """Get the attachment metadata."""
  218. return self._get_json('attachment/meta')
  219. @translate_resource_args
  220. def add_attachment(self, issue, attachment, filename=None):
  221. """
  222. Attach an attachment to an issue and returns a Resource for it.
  223. The client will *not* attempt to open or validate the attachment; it expects a file-like object to be ready
  224. for its use. The user is still responsible for tidying up (e.g., closing the file, killing the socket, etc.)
  225. :param issue: the issue to attach the attachment to
  226. :param attachment: file-like object to attach to the issue, also works if it is a string with the filename.
  227. :param filename: optional name for the attached file. If omitted, the file object's ``name`` attribute
  228. is used. If you aquired the file-like object by any other method than ``open()``, make sure
  229. that a name is specified in one way or the other.
  230. :rtype: an Attachment Resource
  231. """
  232. if isinstance(attachment, string_types):
  233. attachment = open(attachment, "rb")
  234. # TODO: Support attaching multiple files at once?
  235. url = self._get_url('issue/' + str(issue) + '/attachments')
  236. fname = filename
  237. if not fname:
  238. fname = os.path.basename(attachment.name)
  239. content_type = mimetypes.guess_type(fname)[0]
  240. if not content_type:
  241. content_type = 'application/octet-stream'
  242. files = {
  243. 'file': (fname, attachment, content_type)
  244. }
  245. r = self._session.post(url, files=files, headers=self._options['headers'])
  246. raise_on_error(r)
  247. attachment = Attachment(self._options, self._session, json.loads(r.text)[0])
  248. return attachment
  249. # Components
  250. def component(self, id):
  251. """
  252. Get a component Resource from the server.
  253. :param id: ID of the component to get
  254. """
  255. return self._find_for_resource(Component, id)
  256. @translate_resource_args
  257. def create_component(self, name, project, description=None, leadUserName=None, assigneeType=None,
  258. isAssigneeTypeValid=False):
  259. """
  260. Create a component inside a project and return a Resource for it.
  261. :param name: name of the component
  262. :param project: key of the project to create the component in
  263. :param description: a description of the component
  264. :param leadUserName: the username of the user responsible for this component
  265. :param assigneeType: see the ComponentBean.AssigneeType class for valid values
  266. :param isAssigneeTypeValid: boolean specifying whether the assignee type is acceptable
  267. """
  268. data = {
  269. 'name': name,
  270. 'project': project,
  271. 'isAssigneeTypeValid': isAssigneeTypeValid
  272. }
  273. if description is not None:
  274. data['description'] = description
  275. if leadUserName is not None:
  276. data['leadUserName'] = leadUserName
  277. if assigneeType is not None:
  278. data['assigneeType'] = assigneeType
  279. url = self._get_url('component')
  280. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  281. raise_on_error(r)
  282. component = Component(self._options, self._session, raw=json.loads(r.text))
  283. return component
  284. def component_count_related_issues(self, id):
  285. """
  286. Get the count of related issues for a component.
  287. :param id: ID of the component to use
  288. """
  289. return self._get_json('component/' + id + '/relatedIssueCounts')['issueCount']
  290. # Custom field options
  291. def custom_field_option(self, id):
  292. """
  293. Get a custom field option Resource from the server.
  294. :param id: ID of the custom field to use
  295. """
  296. return self._find_for_resource(CustomFieldOption, id)
  297. # Dashboards
  298. def dashboards(self, filter=None, startAt=0, maxResults=20):
  299. """
  300. Return a ResultList of Dashboard resources and a ``total`` count.
  301. :param filter: either "favourite" or "my", the type of dashboards to return
  302. :param startAt: index of the first dashboard to return
  303. :param maxResults: maximum number of dashboards to return. The total number of
  304. results is always available in the ``total`` attribute of the returned ResultList.
  305. """
  306. params = {}
  307. if filter is not None:
  308. params['filter'] = filter
  309. params['startAt'] = startAt
  310. params['maxResults'] = maxResults
  311. r_json = self._get_json('dashboard', params=params)
  312. dashboards = [Dashboard(self._options, self._session, raw_dash_json) for raw_dash_json in r_json['dashboards']]
  313. return ResultList(dashboards, r_json['total'])
  314. def dashboard(self, id):
  315. """
  316. Get a dashboard Resource from the server.
  317. :param id: ID of the dashboard to get.
  318. """
  319. return self._find_for_resource(Dashboard, id)
  320. # Fields
  321. # non-resource
  322. def fields(self):
  323. """Return a list of all issue fields."""
  324. return self._get_json('field')
  325. # Filters
  326. def filter(self, id):
  327. """
  328. Get a filter Resource from the server.
  329. :param id: ID of the filter to get.
  330. """
  331. return self._find_for_resource(Filter, id)
  332. def favourite_filters(self):
  333. """Get a list of filter Resources which are the favourites of the currently authenticated user."""
  334. r_json = self._get_json('filter/favourite')
  335. filters = [Filter(self._options, self._session, raw_filter_json) for raw_filter_json in r_json]
  336. return filters
  337. def create_filter(self, name=None, description=None,
  338. jql=None, favourite=None):
  339. """
  340. Create a new filter and return a filter Resource for it.
  341. Keyword arguments:
  342. name -- name of the new filter
  343. description -- useful human readable description of the new filter
  344. jql -- query string that defines the filter
  345. favourite -- whether to add this filter to the current user's favorites
  346. """
  347. data = {}
  348. if name is not None:
  349. data['name'] = name
  350. if description is not None:
  351. data['description'] = description
  352. if jql is not None:
  353. data['jql'] = jql
  354. if favourite is not None:
  355. data['favourite'] = favourite
  356. url = self._get_url('filter')
  357. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  358. raise_on_error(r)
  359. raw_filter_json = json.loads(r.text)
  360. return Filter(self._options, self._session, raw=raw_filter_json)
  361. # Groups
  362. # non-resource
  363. def groups(self, query=None, exclude=None, maxResults=None):
  364. """
  365. Return a list of groups matching the specified criteria.
  366. Keyword arguments:
  367. query -- filter groups by name with this string
  368. exclude -- filter out groups by name with this string
  369. maxResults -- maximum results to return. defaults to system property jira.ajax.autocomplete.limit (20)
  370. """
  371. params = {}
  372. if query is not None:
  373. params['query'] = query
  374. if exclude is not None:
  375. params['exclude'] = exclude
  376. if maxResults is not None:
  377. params['maxResults'] = maxResults
  378. return self._get_json('groups/picker', params=params)
  379. def group_members(self, group):
  380. """
  381. Return a hash or users with their information. Requires JIRA 6.0 or will raise NotImplemented.
  382. """
  383. if self.version < (6, 0, 0):
  384. raise NotImplementedError("Group members is not implemented in JIRA before version 6.0, upgrade the instance, if possible.")
  385. params = {'groupname': group, 'expand': "users"}
  386. r = self._get_json('group', params=params)
  387. size = r['users']['size']
  388. end_index = r['users']['end-index']
  389. while end_index < size - 1:
  390. params = {'groupname': group, 'expand': "users[%s:%s]" % (end_index + 1, end_index + 50)}
  391. r2 = self._get_json('group', params=params)
  392. for user in r2['users']['items']:
  393. r['users']['items'].append(user)
  394. end_index = r2['users']['end-index']
  395. size = r['users']['size']
  396. #print(end_index, size)
  397. result = {}
  398. for user in r['users']['items']:
  399. result[user['name']] = {'fullname': user['displayName'], 'email': user['emailAddress'], 'active': user['active']}
  400. return result
  401. # Issues
  402. def issue(self, id, fields=None, expand=None):
  403. """
  404. Get an issue Resource from the server.
  405. :param id: ID or key of the issue to get
  406. :param fields: comma-separated string of issue fields to include in the results
  407. :param expand: extra information to fetch inside each resource
  408. """
  409. issue = Issue(self._options, self._session)
  410. params = {}
  411. if fields is not None:
  412. params['fields'] = fields
  413. if expand is not None:
  414. params['expand'] = expand
  415. issue.find(id, params=params)
  416. return issue
  417. def create_issue(self, fields=None, prefetch=True, **fieldargs):
  418. """
  419. Create a new issue and return an issue Resource for it.
  420. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value
  421. is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments
  422. will be ignored.
  423. By default, the client will immediately reload the issue Resource created by this method in order to return
  424. a complete Issue object to the caller; this behavior can be controlled through the 'prefetch' argument.
  425. JIRA projects may contain many different issue types. Some issue screens have different requirements for
  426. fields in a new issue. This information is available through the 'createmeta' method. Further examples are
  427. available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue
  428. :param fields: a dict containing field names and the values to use. If present, all other keyword arguments\
  429. will be ignored
  430. :param prefetch: whether to reload the created issue Resource so that all of its data is present in the value\
  431. returned from this method
  432. """
  433. data = {}
  434. if fields is not None:
  435. data['fields'] = fields
  436. else:
  437. fields_dict = {}
  438. for field in fieldargs:
  439. fields_dict[field] = fieldargs[field]
  440. data['fields'] = fields_dict
  441. url = self._get_url('issue')
  442. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  443. raise_on_error(r)
  444. raw_issue_json = json.loads(r.text)
  445. if prefetch:
  446. return self.issue(raw_issue_json['key'])
  447. else:
  448. return Issue(self._options, self._session, raw=raw_issue_json)
  449. def createmeta(self, projectKeys=None, projectIds=None, issuetypeIds=None, issuetypeNames=None, expand=None):
  450. """
  451. Gets the metadata required to create issues, optionally filtered by projects and issue types.
  452. :param projectKeys: keys of the projects to filter the results with. Can be a single value or a comma-delimited\
  453. string. May be combined with projectIds.
  454. :param projectIds: IDs of the projects to filter the results with. Can be a single value or a comma-delimited\
  455. string. May be combined with projectKeys.
  456. :param issuetypeIds: IDs of the issue types to filter the results with. Can be a single value or a\
  457. comma-delimited string. May be combined with issuetypeNames.
  458. :param issuetypeNames: Names of the issue types to filter the results with. Can be a single value or a\
  459. comma-delimited string. May be combined with issuetypeIds.
  460. :param expand: extra information to fetch inside each resource.
  461. """
  462. params = {}
  463. if projectKeys is not None:
  464. params['projectKeys'] = projectKeys
  465. if projectIds is not None:
  466. params['projectIds'] = projectIds
  467. if issuetypeIds is not None:
  468. params['issuetypeIds'] = issuetypeIds
  469. if issuetypeNames is not None:
  470. params['issuetypeNames'] = issuetypeNames
  471. if expand is not None:
  472. params['expand'] = expand
  473. return self._get_json('issue/createmeta', params)
  474. # non-resource
  475. @translate_resource_args
  476. def assign_issue(self, issue, assignee):
  477. """
  478. Assign an issue to a user.
  479. :param issue: the issue to assign
  480. :param assignee: the user to assign the issue to
  481. """
  482. url = self._options['server'] + '/rest/api/2/issue/' + str(issue) + '/assignee'
  483. payload = {'name': assignee}
  484. r = self._session.put(url, headers={'content-type': 'application/json'}, data=json.dumps(payload))
  485. raise_on_error(r)
  486. @translate_resource_args
  487. def comments(self, issue):
  488. """
  489. Get a list of comment Resources.
  490. :param issue: the issue to get comments from
  491. """
  492. r_json = self._get_json('issue/' + str(issue) + '/comment')
  493. comments = [Comment(self._options, self._session, raw_comment_json) for raw_comment_json in r_json['comments']]
  494. return comments
  495. @translate_resource_args
  496. def comment(self, issue, comment):
  497. """
  498. Get a comment Resource from the server for the specified ID.
  499. :param issue: ID or key of the issue to get the comment from
  500. :param comment: ID of the comment to get
  501. """
  502. return self._find_for_resource(Comment, (issue, comment))
  503. @translate_resource_args
  504. def add_comment(self, issue, body, visibility=None):
  505. """
  506. Add a comment from the current authenticated user on the specified issue and return a Resource for it.
  507. The issue identifier and comment body are required.
  508. :param issue: ID or key of the issue to add the comment to
  509. :param body: Text of the comment to add
  510. :param visibility: a dict containing two entries: "type" and "value". "type" is 'role' (or 'group' if the JIRA\
  511. server has configured comment visibility for groups) and 'value' is the name of the role (or group) to which\
  512. viewing of this comment will be restricted.
  513. """
  514. data = {
  515. 'body': body
  516. }
  517. if visibility is not None:
  518. data['visibility'] = visibility
  519. url = self._get_url('issue/' + str(issue) + '/comment')
  520. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  521. raise_on_error(r)
  522. comment = Comment(self._options, self._session, raw=json.loads(r.text))
  523. return comment
  524. # non-resource
  525. @translate_resource_args
  526. def editmeta(self, issue):
  527. """
  528. Get the edit metadata for an issue.
  529. :param issue: the issue to get metadata for
  530. """
  531. return self._get_json('issue/' + str(issue) + '/editmeta')
  532. @translate_resource_args
  533. def remote_links(self, issue):
  534. """
  535. Get a list of remote link Resources from an issue.
  536. :param issue: the issue to get remote links from
  537. """
  538. r_json = self._get_json('issue/' + str(issue) + '/remotelink')
  539. remote_links = [RemoteLink(self._options, self._session, raw_remotelink_json) for raw_remotelink_json in r_json]
  540. return remote_links
  541. @translate_resource_args
  542. def remote_link(self, issue, id):
  543. """
  544. Get a remote link Resource from the server.
  545. :param issue: the issue holding the remote link
  546. :param id: ID of the remote link
  547. """
  548. return self._find_for_resource(RemoteLink, (issue, id))
  549. @translate_resource_args
  550. def add_remote_link(self, issue, object, globalId=None, application=None, relationship=None):
  551. """
  552. Add a remote link from an issue to an external application and returns a remote link Resource
  553. for it. ``object`` should be a dict containing at least ``url`` to the linked external URL and
  554. ``title`` to display for the link inside JIRA.
  555. For definitions of the allowable fields for ``object`` and the keyword arguments ``globalId``, ``application``
  556. and ``relationship``, see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links.
  557. :param issue: the issue to add the remote link to
  558. :param object: the link details to add (see the above link for details)
  559. :param globalId: unique ID for the link (see the above link for details)
  560. :param application: application information for the link (see the above link for details)
  561. :param relationship: relationship description for the link (see the above link for details)
  562. """
  563. data = {
  564. 'object': object
  565. }
  566. if globalId is not None:
  567. data['globalId'] = globalId
  568. if application is not None:
  569. data['application'] = application
  570. if relationship is not None:
  571. data['relationship'] = relationship
  572. url = self._get_url('issue/' + str(issue) + '/remotelink')
  573. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  574. raise_on_error(r)
  575. remote_link = RemoteLink(self._options, self._session, raw=json.loads(r.text))
  576. return remote_link
  577. # non-resource
  578. @translate_resource_args
  579. def transitions(self, issue, id=None, expand=None):
  580. """
  581. Get a list of the transitions available on the specified issue to the current user.
  582. :param issue: ID or key of the issue to get the transitions from
  583. :param id: if present, get only the transition matching this ID
  584. :param expand: extra information to fetch inside each transition
  585. """
  586. params = {}
  587. if id is not None:
  588. params['transitionId'] = id
  589. if expand is not None:
  590. params['expand'] = expand
  591. return self._get_json('issue/' + str(issue) + '/transitions', params)['transitions']
  592. @translate_resource_args
  593. def transition_issue(self, issue, transitionId, fields=None, comment=None, **fieldargs):
  594. # TODO: Support update verbs (same as issue.update())
  595. """
  596. Perform a transition on an issue.
  597. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value
  598. is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments
  599. will be ignored. Field values will be set on the issue as part of the transition process.
  600. :param issue: ID or key of the issue to perform the transition on
  601. :param transitionId: ID of the transition to perform
  602. :param comment: *Optional* String to add as comment to the issue when performing the transition.
  603. :param fields: a dict containing field names and the values to use. If present, all other keyword arguments\
  604. will be ignored
  605. """
  606. data = {
  607. 'transition': {
  608. 'id': transitionId
  609. }
  610. }
  611. if comment:
  612. data['update'] = {'comment': [{'add': {'body': comment}}]}
  613. if fields is not None:
  614. data['fields'] = fields
  615. else:
  616. fields_dict = {}
  617. for field in fieldargs:
  618. fields_dict[field] = fieldargs[field]
  619. data['fields'] = fields_dict
  620. url = self._get_url('issue/' + str(issue) + '/transitions')
  621. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  622. raise_on_error(r)
  623. @translate_resource_args
  624. def votes(self, issue):
  625. """
  626. Get a votes Resource from the server.
  627. :param issue: ID or key of the issue to get the votes for
  628. """
  629. return self._find_for_resource(Votes, issue)
  630. @translate_resource_args
  631. def add_vote(self, issue):
  632. """
  633. Register a vote for the current authenticated user on an issue.
  634. :param issue: ID or key of the issue to vote on
  635. """
  636. url = self._get_url('issue/' + str(issue) + '/votes')
  637. r = self._session.post(url)
  638. raise_on_error(r)
  639. @translate_resource_args
  640. def remove_vote(self, issue):
  641. """
  642. Remove the current authenticated user's vote from an issue.
  643. :param issue: ID or key of the issue to unvote on
  644. """
  645. url = self._get_url('issue/' + str(issue) + '/votes')
  646. self._session.delete(url)
  647. @translate_resource_args
  648. def watchers(self, issue):
  649. """
  650. Get a watchers Resource from the server for an issue.
  651. :param issue: ID or key of the issue to get the watchers for
  652. """
  653. return self._find_for_resource(Watchers, issue)
  654. @translate_resource_args
  655. def add_watcher(self, issue, watcher):
  656. """
  657. Add a user to an issue's watchers list.
  658. :param issue: ID or key of the issue affected
  659. :param watcher: username of the user to add to the watchers list
  660. """
  661. url = self._get_url('issue/' + str(issue) + '/watchers')
  662. self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(watcher))
  663. @translate_resource_args
  664. def remove_watcher(self, issue, watcher):
  665. """
  666. Remove a user from an issue's watch list.
  667. :param issue: ID or key of the issue affected
  668. :param watcher: username of the user to remove from the watchers list
  669. """
  670. url = self._get_url('issue/' + str(issue) + '/watchers')
  671. params = {'username': watcher}
  672. self._session.delete(url, params=params)
  673. @translate_resource_args
  674. def worklogs(self, issue):
  675. """
  676. Get a list of worklog Resources from the server for an issue.
  677. :param issue: ID or key of the issue to get worklogs from
  678. """
  679. r_json = self._get_json('issue/' + str(issue) + '/worklog')
  680. worklogs = [Worklog(self._options, self._session, raw_worklog_json) for raw_worklog_json in r_json['worklogs']]
  681. return worklogs
  682. @translate_resource_args
  683. def worklog(self, issue, id):
  684. """
  685. Get a specific worklog Resource from the server.
  686. :param issue: ID or key of the issue to get the worklog from
  687. :param id: ID of the worklog to get
  688. """
  689. return self._find_for_resource(Worklog, (issue, id))
  690. @translate_resource_args
  691. def add_worklog(self, issue, timeSpent=None, adjustEstimate=None,
  692. newEstimate=None, reduceBy=None, comment=None):
  693. """
  694. Add a new worklog entry on an issue and return a Resource for it.
  695. :param issue: the issue to add the worklog to
  696. :param timeSpent: a worklog entry with this amount of time spent, e.g. "2d"
  697. :param adjustEstimate: (optional) allows the user to provide specific instructions to update the remaining\
  698. time estimate of the issue. The value can either be ``new``, ``leave``, ``manual`` or ``auto`` (default).
  699. :param newEstimate: the new value for the remaining estimate field. e.g. "2d"
  700. :param reduceBy: the amount to reduce the remaining estimate by e.g. "2d"
  701. :param comment: optional worklog comment
  702. """
  703. params = {}
  704. if adjustEstimate is not None:
  705. params['adjustEstimate'] = adjustEstimate
  706. if newEstimate is not None:
  707. params['newEstimate'] = newEstimate
  708. if reduceBy is not None:
  709. params['reduceBy'] = reduceBy
  710. data = {}
  711. if timeSpent is not None:
  712. data['timeSpent'] = timeSpent
  713. if comment is not None:
  714. data['comment'] = comment
  715. url = self._get_url('issue/{}/worklog'.format(issue))
  716. r = self._session.post(url, params=params, headers={'content-type': 'application/json'}, data=json.dumps(data))
  717. raise_on_error(r)
  718. return Worklog(self._options, self._session, json.loads(r.text))
  719. # Issue links
  720. @translate_resource_args
  721. def create_issue_link(self, type, inwardIssue, outwardIssue, comment=None):
  722. """
  723. Create a link between two issues.
  724. :param type: the type of link to create
  725. :param inwardIssue: the issue to link from
  726. :param outwardIssue: the issue to link to
  727. :param comment: a comment to add to the issues with the link. Should be a dict containing ``body``\
  728. and ``visibility`` fields: ``body`` being the text of the comment and ``visibility`` being a dict containing\
  729. two entries: ``type`` and ``value``. ``type`` is ``role`` (or ``group`` if the JIRA server has configured\
  730. comment visibility for groups) and ``value`` is the name of the role (or group) to which viewing of this\
  731. comment will be restricted.
  732. """
  733. # let's see if we have the right issue link 'type' and fix it if needed
  734. if not hasattr(self, '_cached_issuetypes'):
  735. self._cached_issue_link_types = self.issue_link_types()
  736. if type not in self._cached_issue_link_types:
  737. for lt in self._cached_issue_link_types:
  738. if lt.outward == type:
  739. type = lt.name # we are smart to figure it out what he ment
  740. break
  741. elif lt.inward == type:
  742. type = lt.name # so that's the reverse, so we fix the request
  743. inwardIssue, outwardIssue = outwardIssue, inwardIssue
  744. break
  745. data = {
  746. 'type': {
  747. 'name': type
  748. },
  749. 'inwardIssue': {
  750. 'key': inwardIssue
  751. },
  752. 'outwardIssue': {
  753. 'key': outwardIssue
  754. },
  755. 'comment': comment
  756. }
  757. url = self._get_url('issueLink')
  758. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  759. raise_on_error(r)
  760. def issue_link(self, id):
  761. """
  762. Get an issue link Resource from the server.
  763. :param id: ID of the issue link to get
  764. """
  765. return self._find_for_resource(IssueLink, id)
  766. # Issue link types
  767. def issue_link_types(self):
  768. """Get a list of issue link type Resources from the server."""
  769. r_json = self._get_json('issueLinkType')
  770. link_types = [IssueLinkType(self._options, self._session, raw_link_json) for raw_link_json in r_json['issueLinkTypes']]
  771. return link_types
  772. def issue_link_type(self, id):
  773. """
  774. Get an issue link type Resource from the server.
  775. :param id: ID of the issue link type to get
  776. """
  777. return self._find_for_resource(IssueLinkType, id)
  778. # Issue types
  779. def issue_types(self):
  780. """Get a list of issue type Resources from the server."""
  781. r_json = self._get_json('issuetype')
  782. issue_types = [IssueType(self._options, self._session, raw_type_json) for raw_type_json in r_json]
  783. return issue_types
  784. def issue_type(self, id):
  785. """
  786. Get an issue type Resource from the server.
  787. :param id: ID of the issue type to get
  788. """
  789. return self._find_for_resource(IssueType, id)
  790. # User permissions
  791. # non-resource
  792. def my_permissions(self, projectKey=None, projectId=None, issueKey=None, issueId=None):
  793. """
  794. Get a dict of all available permissions on the server.
  795. :param projectKey: limit returned permissions to the specified project
  796. :param projectId: limit returned permissions to the specified project
  797. :param issueKey: limit returned permissions to the specified issue
  798. :param issueId: limit returned permissions to the specified issue
  799. """
  800. params = {}
  801. if projectKey is not None:
  802. params['projectKey'] = projectKey
  803. if projectId is not None:
  804. params['projectId'] = projectId
  805. if issueKey is not None:
  806. params['issueKey'] = issueKey
  807. if issueId is not None:
  808. params['issueId'] = issueId
  809. return self._get_json('mypermissions', params=params)
  810. # Priorities
  811. def priorities(self):
  812. """Get a list of priority Resources from the server."""
  813. r_json = self._get_json('priority')
  814. priorities = [Priority(self._options, self._session, raw_priority_json) for raw_priority_json in r_json]
  815. return priorities
  816. def priority(self, id):
  817. """
  818. Get a priority Resource from the server.
  819. :param id: ID of the priority to get
  820. """
  821. return self._find_for_resource(Priority, id)
  822. # Projects
  823. def projects(self):
  824. """Get a list of project Resources from the server visible to the current authenticated user."""
  825. r_json = self._get_json('project')
  826. projects = [Project(self._options, self._session, raw_project_json) for raw_project_json in r_json]
  827. return projects
  828. def project(self, id):
  829. """
  830. Get a project Resource from the server.
  831. :param id: ID or key of the project to get
  832. """
  833. return self._find_for_resource(Project, id)
  834. # non-resource
  835. @translate_resource_args
  836. def project_avatars(self, project):
  837. """
  838. Get a dict of all avatars for a project visible to the current authenticated user.
  839. :param project: ID or key of the project to get avatars for
  840. """
  841. return self._get_json('project/' + project + '/avatars')
  842. @translate_resource_args
  843. def create_temp_project_avatar(self, project, filename, size, avatar_img, contentType=None, auto_confirm=False):
  844. """
  845. Register an image file as a project avatar. The avatar created is temporary and must be confirmed before it can
  846. be used.
  847. Avatar images are specified by a filename, size, and file object. By default, the client will attempt to
  848. autodetect the picture's content type: this mechanism relies on libmagic and will not work out of the box
  849. on Windows systems (see http://filemagic.readthedocs.org/en/latest/guide.html for details on how to install
  850. support). The ``contentType`` argument can be used to explicitly set the value (note that JIRA will reject any
  851. type other than the well-known ones for images, e.g. ``image/jpg``, ``image/png``, etc.)
  852. This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This
  853. dict should be saved and passed to :py:meth:`confirm_project_avatar` to finish the avatar creation process. If\
  854. you want to cut out the middleman and confirm the avatar with JIRA's default cropping, pass the 'auto_confirm'\
  855. argument with a truthy value and :py:meth:`confirm_project_avatar` will be called for you before this method\
  856. returns.
  857. :param project: ID or key of the project to create the avatar in
  858. :param filename: name of the avatar file
  859. :param size: size of the avatar file
  860. :param avatar_img: file-like object holding the avatar
  861. :param contentType: explicit specification for the avatar image's content-type
  862. :param boolean auto_confirm: whether to automatically confirm the temporary avatar by calling\
  863. :py:meth:`confirm_project_avatar` with the return value of this method.
  864. """
  865. size_from_file = os.path.getsize(filename)
  866. if size != size_from_file:
  867. size = size_from_file
  868. params = {
  869. 'filename': filename,
  870. 'size': size
  871. }
  872. headers = {'X-Atlassian-Token': 'no-check'}
  873. if contentType is not None:
  874. headers['content-type'] = contentType
  875. else:
  876. # try to detect content-type, this may return None
  877. headers['content-type'] = self._get_mime_type(avatar_img)
  878. url = self._get_url('project/' + project + '/avatar/temporary')
  879. r = self._session.post(url, params=params, headers=headers, data=avatar_img)
  880. raise_on_error(r)
  881. cropping_properties = json.loads(r.text)
  882. if auto_confirm:
  883. return self.confirm_project_avatar(project, cropping_properties)
  884. else:
  885. return cropping_properties
  886. @translate_resource_args
  887. def confirm_project_avatar(self, project, cropping_properties):
  888. """
  889. Confirm the temporary avatar image previously uploaded with the specified cropping.
  890. After a successful registry with :py:meth:`create_temp_project_avatar`, use this method to confirm the avatar
  891. for use. The final avatar can be a subarea of the uploaded image, which is customized with the
  892. ``cropping_properties``: the return value of :py:meth:`create_temp_project_avatar` should be used for this
  893. argument.
  894. :param project: ID or key of the project to confirm the avatar in
  895. :param cropping_properties: a dict of cropping properties from :py:meth:`create_temp_project_avatar`
  896. """
  897. data = cropping_properties
  898. url = self._get_url('project/' + project + '/avatar')
  899. r = self._session.post(url, headers={'content-type': 'application/json'}, data=json.dumps(data))
  900. raise_on_error(r)
  901. return json.loads(r.text)
  902. @translate_resource_args
  903. def set_project_avatar(self, project, avatar):
  904. """
  905. Set a project's avatar.
  906. :param project: ID or key of the project to set the avatar on
  907. :param avatar: ID of the avatar to set
  908. """
  909. self._set_avatar(None, self._get_url('project/' + project + '/avatar'), avatar)
  910. @translate_resource_args
  911. def delete_project_avatar(self, project, avatar):
  912. """
  913. Delete a project's avatar.
  914. :param project: ID or key of the project to delete the avatar from
  915. :param avatar: ID of the avater to delete
  916. """
  917. url = self._get_url('project/' + project + '/avatar/' + avatar)
  918. r = self._session.delete(url)
  919. raise_on_error(r)
  920. @translate_resource_args
  921. def project_components(self, project):
  922. """
  923. Get a list of component Resources present on a project.
  924. :param project: ID or key of the project to get components from
  925. """
  926. r_json = self._get_json('project/' + project + '/components')
  927. components = [Component(self._options, self._session, raw_comp_json) for raw_comp_json in r_json]
  928. return components
  929. @translate_resource_args
  930. def project_versions(self, project):
  931. """
  932. Get a list of version Resources present on a project.
  933. :param project: ID or key of the project to get versions from
  934. """
  935. r_json = self._get_json('project/' + project + '/versions')
  936. versions = [Version(self._options, self._session, raw_ver_json) for raw_ver_json in r_json]
  937. return versions
  938. # non-resource
  939. @translate_resource_args
  940. def project_roles(self, project):
  941. """
  942. Get a dict of role names to resource locations for a project.
  943. :param project: ID or key of the project to get roles from
  944. """
  945. return self._get_json('project/' + project + '/role')
  946. @translate_resource_args
  947. def project_role(self, project, id):
  948. """
  949. Get a role Resource.
  950. :param project: ID or key of the project to get the role from
  951. :param id: ID of the role to get
  952. """
  953. return self._find_for_resource(Role, (project, id))
  954. # Resolutions
  955. def resolutions(self):
  956. """Get a list of resolution Resources from the server."""
  957. r_json = self._get_json('resolution')
  958. resolutions = [Resolution(self._options, self._session, raw_res_json) for raw_res_json in r_json]
  959. return resolutions
  960. def resolution(self, id):
  961. """
  962. Get a resolution Resource from the server.
  963. :param id: ID of the resolution to get
  964. """
  965. return self._find_for_resource(Resolution, id)
  966. # Search
  967. def search_issues(self, jql_str, startAt=0, maxResults=50, fields=None, expand=None, json_result=None):
  968. """
  969. Get a ResultList of issue Resources matching a JQL search string.
  970. :param jql_str: the JQL search string to use
  971. :param startAt: index of the first issue to return
  972. :param maxResults: maximum number of issues to return. Total number of results
  973. is available in the ``total`` attribute of the returned ResultList.
  974. If maxResults evaluates as False, it will try to get all issues in batches of 50.
  975. :param fields: comma-separated string of issue fields to include in the results
  976. :param expand: extra information to fetch inside each resource
  977. """
  978. # TODO what to do about the expand, which isn't related to the issues?
  979. infinite = False
  980. maxi = 50
  981. idx = 0
  982. if fields is None:
  983. fields = []
  984. if maxResults is None or not maxResults:
  985. maxResults = maxi
  986. infinite = True
  987. search_params = {
  988. "jql": jql_str,
  989. "startAt": startAt,
  990. "maxResults": maxResults,
  991. "fields": fields,
  992. "expand": expand
  993. }
  994. if json_result:
  995. return self._get_json('search', search_params)
  996. resource = self._get_json('search', search_params)
  997. issues = [Issue(self._options, self._session, raw_issue_json) for raw_issue_json in resource['issues']]
  998. cnt = len(issues)
  999. total = resource['total']
  1000. if infinite:
  1001. while cnt == maxi:
  1002. idx += maxi
  1003. search_params["startAt"] = idx
  1004. resource = self._get_json('search', search_params)
  1005. issue_batch = [Issue(self._options, self._session, raw_issue_json) for raw_issue_json in resource['issues']]
  1006. issues.extend(issue_batch)
  1007. cnt = len(issue_batch)
  1008. return ResultList(issues, total)
  1009. # Security levels
  1010. def security_level(self, id):
  1011. """
  1012. Get a security level Resource.
  1013. :param id: ID of the security level to get
  1014. """
  1015. return self._find_for_resource(SecurityLevel, id)
  1016. # Server info
  1017. # non-resource
  1018. def server_info(self):
  1019. """Get a dict of server information for this JIRA instance."""
  1020. return self._get_json('serverInfo')
  1021. # Status
  1022. def statuses(self):
  1023. """Get a list of status Resources from the server."""
  1024. r_json = self._get_json('status')
  1025. statuses = [Status(self._options, self._session, raw_stat_json) for raw_stat_json in r_json]
  1026. return statuses
  1027. def status(self, id):
  1028. """
  1029. Get a status Resource from the server.
  1030. :param id: ID of the status resource to get
  1031. """
  1032. return self._find_for_resource(Status, id)
  1033. # Users
  1034. def user(self, id, expand=None):
  1035. """
  1036. Get a user Resource from the server.
  1037. :param id: ID of the user to get
  1038. :param expand: extra information to fetch inside each resource
  1039. """
  1040. user = User(self._options, self._session)
  1041. params = {}
  1042. if expand is not None:
  1043. params['expand'] = expand
  1044. user.find(id, params=params)
  1045. return user
  1046. def search_assignable_users_for_projects(self, username, projectKeys, startAt=0, maxResults=50):
  1047. """
  1048. Get a list of user Resources that match the search string and can be assigne

Large files files are truncated, but you can click here to view the full file