pax_global_header00006660000000000000000000000064130474117370014521gustar00rootroot0000000000000052 comment=ff7fe0b8cd901e6d84abafbc10a5eab405568649 jira-1.0.10/000077500000000000000000000000001304741173700125255ustar00rootroot00000000000000jira-1.0.10/.coveragerc000066400000000000000000000003441304741173700146470ustar00rootroot00000000000000[run] data_file = .coverage source = jira branch = True [report] exclude_lines = pragma: no cover def __repr__ raise AssertionError raise NotImplementedError if __name__ == .__main__.: ignore_errors = True jira-1.0.10/.coveralls.yml000066400000000000000000000000561304741173700153210ustar00rootroot00000000000000repo_token: TagM2COnwKYL3YaS7He9DkBPA7GFDQwsH jira-1.0.10/.gitchangelog.rc000066400000000000000000000134201304741173700155640ustar00rootroot00000000000000## ## Format ## ## ACTION: [AUDIENCE:] COMMIT_MSG [!TAG ...] ## ## Description ## ## ACTION is one of 'chg', 'fix', 'new' ## ## Is WHAT the change is about. ## ## 'chg' is for refactor, small improvement, cosmetic changes... ## 'fix' is for bug fixes ## 'new' is for new features, big improvement ## ## AUDIENCE is optional and one of 'dev', 'usr', 'pkg', 'test', 'doc' ## ## Is WHO is concerned by the change. ## ## 'dev' is for developpers (API changes, refactors...) ## 'usr' is for final users (UI changes) ## 'pkg' is for packagers (packaging changes) ## 'test' is for testers (test only related changes) ## 'doc' is for doc guys (doc only changes) ## ## COMMIT_MSG is ... well ... the commit message itself. ## ## TAGs are additionnal adjective as 'refactor' 'minor' 'cosmetic' ## ## They are preceded with a '!' or a '@' (prefer the former, as the ## latter is wrongly interpreted in github.) Commonly used tags are: ## ## 'refactor' is obviously for refactoring code only ## 'minor' is for a very meaningless change (a typo, adding a comment) ## 'cosmetic' is for cosmetic driven change (re-indentation, 80-col...) ## 'wip' is for partial functionality but complete subfunctionality. ## ## Example: ## ## new: usr: support of bazaar implemented ## chg: re-indentend some lines !cosmetic ## new: dev: updated code to be compatible with last version of killer lib. ## fix: pkg: updated year of licence coverage. ## new: test: added a bunch of test around user usability of feature X. ## fix: typo in spelling my name in comment. !minor ## ## Please note that multi-line commit message are supported, and only the ## first line will be considered as the "summary" of the commit message. So ## tags, and other rules only applies to the summary. The body of the commit ## message will be displayed in the changelog without reformatting. ## ## ``ignore_regexps`` is a line of regexps ## ## Any commit having its full commit message matching any regexp listed here ## will be ignored and won't be reported in the changelog. ## ignore_regexps = [ # ignore trivial fixes r'Auto-generating.*', r'spelling|typo', r'bump.*version', # all merged commits in the PR will appear in changelog anyway so # the PR merge commit is not needed. r'Merge pull request #\d+ from.*' ] ## ``section_regexps`` is a list of 2-tuples associating a string label and a ## list of regexp ## ## Commit messages will be classified in sections thanks to this. Section ## titles are the label, and a commit is classified under this section if any ## of the regexps associated is matching. ## section_regexps = [ ('Other', None), ## Match all lines ] ## ``body_process`` is a callable ## ## This callable will be given the original body and result will ## be used in the changelog. ## ## Available constructs are: ## ## - any python callable that take one txt argument and return txt argument. ## ## - ReSub(pattern, replacement): will apply regexp substitution. ## ## - Indent(chars=" "): will indent the text with the prefix ## Please remember that template engines gets also to modify the text and ## will usually indent themselves the text if needed. ## ## - Wrap(regexp=r"\n\n"): re-wrap text in separate paragraph to fill 80-Columns ## ## - noop: do nothing ## ## - ucfirst: ensure the first letter is uppercase. ## (usually used in the ``subject_process`` pipeline) ## ## - final_dot: ensure text finishes with a dot ## (usually used in the ``subject_process`` pipeline) ## ## - strip: remove any spaces before or after the content of the string ## ## Additionally, you can `pipe` the provided filters, for instance: #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') | Indent(chars=" ") #body_process = Wrap(regexp=r'\n(?=\w+\s*:)') #body_process = noop empty_string = lambda _: '' body_process = empty_string ## ``subject_process`` is a callable ## ## This callable will be given the original subject and result will ## be used in the changelog. ## ## Available constructs are those listed in ``body_process`` doc. subject_process = (strip | ReSub(r'^([cC]hg|[fF]ix|[nN]ew)\s*:\s*((dev|use?r|pkg|test|doc)\s*:\s*)?([^\n@]*)(@[a-z]+\s+)*$', r'\4') | ucfirst | final_dot) ## ``tag_filter_regexp`` is a regexp ## ## Tags that will be used for the changelog must match this regexp. ## tag_filter_regexp = r'^[0-9]+\.[0-9]+(\.[0-9]+)?$' ## ``unreleased_version_label`` is a string ## ## This label will be used as the changelog Title of the last set of changes ## between last valid tag and HEAD if any. unreleased_version_label = "Upcoming release (unreleased changes)" ## ``output_engine`` is a callable ## ## This will change the output format of the generated changelog file ## ## Available choices are: ## ## - rest_py ## ## Legacy pure python engine, outputs ReSTructured text. ## This is the default. ## ## - mustache() ## ## Template name could be any of the available templates in ## ``templates/mustache/*.tpl``. ## Requires python package ``pystache``. ## Examples: ## - mustache("markdown") ## - mustache("restructuredtext") ## ## - makotemplate() ## ## Template name could be any of the available templates in ## ``templates/mako/*.tpl``. ## Requires python package ``mako``. ## Examples: ## - makotemplate("restructuredtext") ## output_engine = rest_py #output_engine = mustache("restructuredtext") #output_engine = mustache("markdown") #output_engine = makotemplate("restructuredtext") ## ``include_merge`` is a boolean ## ## This option tells git-log whether to include merge commits in the log. ## The default is to include them. include_merge = True jira-1.0.10/.gitignore000066400000000000000000000004741304741173700145220ustar00rootroot00000000000000.idea/ *.bak *.egg *.egg-info/ *.pyc .cache/ .coverage .coverage.* .eggs/ .tox/ amps-standalone coverage.xml docs/_build docs/build encrypt-credentials.sh reports reports/ setenv.sh settings.py test-quick tests/settings.py tests/test-reports-*/* **/*.log /.python-version /CHANGELOG /ChangeLog /AUTHORS /tests/build jira-1.0.10/.gitreview000066400000000000000000000001371304741173700145340ustar00rootroot00000000000000[gerrit] host=review.gerrithub.io port=29418 project=pycontribs/jira.git defaultbranch=develop jira-1.0.10/.travis.yml000066400000000000000000000045531304741173700146450ustar00rootroot00000000000000language: python sudo: false matrix: fast_finish: false os: - linux python: - '2.7' - '3.4' - '3.5' - '3.6' install: - pip -q --log dist/pip.log install --upgrade pip setuptools tox-travis py wheel - python setup.py sdist bdist_wheel install - pip install ./dist/jira-*.whl - pip --version script: - export PACKAGE_NAME=$(python setup.py --name) - export PACKAGE_VERSION=$(python setup.py --version) - python setup.py --version - tox --installpkg ./dist/jira-*.whl --travis-after # validates that the build source distribution is installable using the old easy_install - pip uninstall -y jira && easy_install ./dist/jira-*.tar.gz after_success: - coveralls - bash <(curl -s https://codecov.io/bash) - requires.io update-site -t ac3bbcca32ae03237a6aae2b02eb9411045489bb -r notifications: email: - pycontribs@googlegroups.com - sorin.sbarnea@gmail.com deploy: - provider: releases api_key: secure: gr9iOcQjdoAyUAim6FWKzJI9MBaJo9XKfGQGu7wdPXUFhg80Rp6GLJsowP+aU94NjXM1UQlVHDAy627WtjBlLH2SvmVEIIr7+UKBopBYuXG5jJ1m3wOZE+4f1Pqe9bqFc1DxgucqE8qF0sC24fIbNM2ToeyYrxrS6RoL2gRrX2I= file: - dist/$PACKAGE_NAME-$PACKAGE_VERSION* - ChangeLog skip_cleanup: true on: tags: false python: 2.7 condition: $TOXENV != docs - provider: pypi user: sorin password: secure: E0cjANF7SLBdYrsnWLK8X/xWznqkF0JrP/DVfDazPzUYH6ynFeneyofzNJQPLTLsqe1eKXhuUJ/Sbl+RHFB0ySo/j/7NfYd/9pm8hpUkGCvR09IwtvMLgWKp3k10NWab03o2GOkSJSrLvZofyZBGR40wwu2O9uXPCb2rvucCGbw= distributions: sdist bdist_wheel skip_cleanup: true on: tags: true python: 2.7 condition: $TOXENV != docs branch: master - provider: pypi server: https://testpypi.python.org/pypi user: sorins password: secure: E0cjANF7SLBdYrsnWLK8X/xWznqkF0JrP/DVfDazPzUYH6ynFeneyofzNJQPLTLsqe1eKXhuUJ/Sbl+RHFB0ySo/j/7NfYd/9pm8hpUkGCvR09IwtvMLgWKp3k10NWab03o2GOkSJSrLvZofyZBGR40wwu2O9uXPCb2rvucCGbw= distributions: sdist bdist_wheel skip_cleanup: true on: tags: false python: 2.7 condition: $TOXENV != docs branch: develop env: global: - secure: fuXwQL+KHQ96XkAFl2uQc8eK8dAjrgkup46tck/UGjVpdv1PT/yHmBKrvpFjDa50ueGbtBwTdKAwhyAmYuiZCk2IYHzdvBylCZBBji2FSpaTM59CVwgkVT6tx3HHO83X0mEX6ih9TJvZD5XhX+YUjopnseRXRq3ey3JZJXWN4RM= - secure: "pGQGM5YmHvOgaKihOyzb3k6bdqLQnZQ2OXO9QrfXlXwtop3zvZQi80Q+01l230x2psDWlwvqWTknAjAt1w463fYXPwpoSvKVCsLSSbjrf2l56nrDqnoir+n0CBy288+eIdaGEfzcxDiuULeKjlg08zrqjcjLjW0bDbBrlTXsb5U=" jira-1.0.10/AUTHORS.rst000066400000000000000000000005601304741173700144050ustar00rootroot00000000000000If you are a contributor, and you are not listed here, feel free to add your name via a pull request. Development Team (PyContribs) ````````````````````````````` - Ben Speakmon - Original Author - Sorin Sbarnea _ Current Maintainer Patches and Suggestions ``````````````````````` - ... and many others. Thank you! jira-1.0.10/LICENSE000066400000000000000000000024121304741173700135310ustar00rootroot00000000000000Copyright (c) 2012, Atlassian Pty Ltd. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.jira-1.0.10/MANIFEST.in000066400000000000000000000001721304741173700142630ustar00rootroot00000000000000include LICENSE README.rst include requirements.txt include requirements-dev.txt include requirements-opt.txt prune tests jira-1.0.10/Makefile000066400000000000000000000110441304741173700141650ustar00rootroot00000000000000all: info clean flake8 test docs upload release .PHONY: all docs upload info req PACKAGE_NAME := $(shell python setup.py --name) PACKAGE_VERSION := $(shell python setup.py --version) PYTHON_PATH := $(shell which python) PLATFORM := $(shell uname -s | awk '{print tolower($0)}') DIR := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) PYTHON_VERSION := $(shell python3 -c "import sys; print('py%s%s' % sys.version_info[0:2] + ('-conda' if 'conda' in sys.version or 'Continuum' in sys.version else ''))") PYENV_HOME := $(DIR)/.tox/$(PYTHON_VERSION)-$(PLATFORM)/ ifneq (,$(findstring conda,$(PYTHON_VERSION))) CONDA:=1 endif ifndef GIT_BRANCH GIT_BRANCH=$(shell git branch | sed -n '/\* /s///p') endif info: @echo "INFO: Building $(PACKAGE_NAME):$(PACKAGE_VERSION) on $(GIT_BRANCH) branch" @echo "INFO: Python $(PYTHON_VERSION) from $(PYENV_HOME) [$(CONDA)]" clean: @find . -name "*.pyc" -delete @rm -rf .tox/*-$(PLATFORM) .tox/docs dist/* .tox/dist .tox/log docs/build/* package: python setup.py sdist bdist_wheel build_sphinx req: @$(PYENV_HOME)/bin/requires.io update-site -t ac3bbcca32ae03237a6aae2b02eb9411045489bb -r $(PACKAGE_NAME) install: prepare $(PYENV_HOME)/bin/pip install . install-sdk: # https://developer.atlassian.com/docs/getting-started/set-up-the-atlassian-plugin-sdk-and-build-a-project/install-the-atlassian-sdk-on-a-linux-or-mac-system#InstalltheAtlassianSDKonaLinuxorMacsystem-Homebrew which atlas-run-standalone || brew tap atlassian/tap && brew install atlassian/tap/atlassian-plugin-sdk uninstall: $(PYENV_HOME)/bin/pip uninstall -y $(PACKAGE_NAME) venv: $(PYENV_HOME)/bin/activate # virtual environment depends on requriements files $(PYENV_HOME)/bin/activate: requirements*.txt @echo "INFO: (Re)creating virtual environment..." ifdef CONDA test -e $(PYENV_HOME)/bin/activate || conda create -y --prefix $(PYENV_HOME) pip else test -e $(PYENV_HOME)/bin/activate || virtualenv --python=$(PYTHON_PATH) --system-site-packages $(PYENV_HOME) endif $(PYENV_HOME)/bin/pip install -q -r requirements.txt -r requirements-opt.txt -r requirements-dev.txt touch $(PYENV_HOME)/bin/activate prepare: venv pyenv install -s 2.7.13 pyenv install -s 3.4.5 pyenv install -s 3.5.2 pyenv install -s 3.6.0 pyenv local 2.7.13 3.4.5 3.5.2 3.6.0 @echo "INFO: === Prearing to run for package:$(PACKAGE_NAME) platform:$(PLATFORM) py:$(PYTHON_VERSION) dir:$(DIR) ===" if [ -f ${HOME}/testspace/testspace ]; then ${HOME}/testspace/testspace config url ${TESTSPACE_TOKEN}@pycontribs.testspace.com/jira/tests ; fi; testspace: ${HOME}/testspace/testspace publish build/results.xml flake8: venv @echo "INFO: flake8" $(PYENV_HOME)/bin/python -m flake8 $(PYENV_HOME)/bin/python -m flake8 --install-hook 2>/dev/null || true test: prepare flake8 @echo "INFO: test" $(PYENV_HOME)/bin/python setup.py build test build_sphinx sdist bdist_wheel check --restructuredtext --strict test-cli: $(PYENV_HOME)/bin/ipython -c "import jira; j = jira.JIRA('https://pycontribs.atlassian.net'); j.server_info()" -i test-all: @echo "INFO: test-all (extended/matrix tests)" # tox should not run inside virtualenv because it does create and use multiple virtualenvs pip install -q tox tox-pyenv python -m tox --skip-missing-interpreters true docs: @echo "INFO: Building the docs" $(PYENV_HOME)/bin/pip install sphinx $(PYENV_HOME)/bin/python setup.py build_sphinx @mkdir -p docs/build/docset @mkdir -p docs/build/html/docset # cannot put doc2dash into requirements.txt file because is using pinned requirements # @DOC2DASH_OPTS=$(shell [ -d "$HOME/Library/Application Support/doc2dash/DocSets" ] && echo '--add-to-global') # doc2dash --force --name jira docs/build/html --destination docs/build/docset --icon docs/_static/python-32.png --online-redirect-url https://jira.readthedocs.io/en/stable/ $(DOC2DASH_OPTS) # cd docs/build/docset && tar --exclude='.DS_Store' -czf ../html/docset/jira.tgz jira.docset # # TODO: publish the docs tag: bumpversion --feature --no-input git push origin master git push --tags release: req ifeq ($(GIT_BRANCH),master) tag else upload web @echo "INFO: Skipping release on this branch." endif upload: ifeq ($(GIT_BRANCH),develop) @echo "INFO: Upload package to testpypi.python.org" $(PYENV_HOME)/bin/python setup.py check --restructuredtext --strict $(PYENV_HOME)/bin/python setup.py sdist bdist_wheel upload -r https://testpypi.python.org/pypi endif ifeq ($(GIT_BRANCH),master) @echo "INFO: Upload package to pypi.python.org" $(PYENV_HOME)/bin/python setup.py check --restructuredtext --strict $(PYENV_HOME)/bin/python setup.py sdist bdist_wheel upload endif jira-1.0.10/README.rst000066400000000000000000000114341304741173700142170ustar00rootroot00000000000000=================== JIRA Python Library =================== .. image:: https://img.shields.io/pypi/v/jira.svg :target: https://pypi.python.org/pypi/jira/ .. image:: https://img.shields.io/pypi/l/jira.svg :target: https://pypi.python.org/pypi/jira/ .. image:: https://img.shields.io/pypi/dm/jira.svg :target: https://pypi.python.org/pypi/jira/ .. image:: https://img.shields.io/pypi/wheel/Django.svg :target: https://pypi.python.org/pypi/jira/ ------------ .. image:: https://readthedocs.org/projects/jira/badge/?version=master :target: http://jira.readthedocs.io .. image:: https://api.travis-ci.org/pycontribs/jira.svg?branch=master :target: https://travis-ci.org/pycontribs/jira .. image:: https://img.shields.io/pypi/status/jira.svg :target: https://pypi.python.org/pypi/jira/ .. image:: https://codecov.io/gh/pycontribs/jira/branch/develop/graph/badge.svg :target: https://codecov.io/gh/pycontribs/jira .. image:: https://img.shields.io/bountysource/team/pycontribs/activity.svg :target: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 .. image:: https://requires.io/github/pycontribs/jira/requirements.svg?branch=master :target: https://requires.io/github/pycontribs/jira/requirements/?branch=master :alt: Requirements Status This library eases the use of the JIRA REST API from Python and it has been used in production for years. As this is an open-source project that is community maintained, do not be surprised if some bugs or features are not implemented quickly enough. You are always welcomed to use BountySource_ to motivate others to help. .. _BountySource: https://www.bountysource.com/teams/pycontribs/issues?tracker_ids=3650997 Quickstart ---------- Feeling impatient? I like your style. .. code-block:: python from jira import JIRA jira = JIRA('https://jira.atlassian.com') issue = jira.issue('JRA-9') print issue.fields.project.key # 'JRA' print issue.fields.issuetype.name # 'New Feature' print issue.fields.reporter.displayName # 'Mike Cannon-Brookes [Atlassian]' Installation ~~~~~~~~~~~~ Download and install using ``pip install jira`` or ``easy_install jira`` You can also try ``pip install --user --upgrade jira`` which will install or upgrade jira to your user directory. Or maybe you ARE using a virtualenv_ right? .. _virtualenv: http://www.virtualenv.org/en/latest/index.html Usage ~~~~~ See the documentation_ for full details. .. _documentation: http://jira.readthedocs.org/en/latest/ Development ~~~~~~~~~~~ Development takes place on GitHub_, where the git-flow_ branch structure is used: * ``master`` - contains the latest released code. * ``develop`` - (default branch) is used for development of the next release. * ``feature/XXX`` - feature branches are used for development of new features before they are merged to ``develop``. .. _GitHub: https://github.com/pycontribs/jira .. _git-flow: http://nvie.com/posts/a-successful-git-branching-model/ Credits ------- In additions to all the contributors we would like to thank to these companies: * Atlassian_ for developing such a powerful issue tracker and for providing a free on-demand JIRA_ instance that we can use for continous integration testing. * JetBrains_ for providing us with free licenses of PyCharm_ * Travis_ for hosting our continous integration * Navicat_ for providing us free licenses of their powerful database client GUI tools. * Citrix_ for providing maintenance of the library. .. _Atlassian: https://www.atlassian.com/ .. _JIRA: https://pycontribs.atlassian.net .. _JetBrains: http://www.jetbrains.com .. _PyCharm: http://www.jetbrains.com/pycharm/ .. _Travis: https://travis-ci.org/ .. _navicat: https://www.navicat.com/ .. _Citrix: http://www.citrix.com/ .. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=https://www.atlassian.com/dms/wac/images/press/Atlassian-logos/logoAtlassianPNG.png :target: http://www.atlassian.com .. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=http://blog.jetbrains.com/pycharm/files/2015/12/PyCharm_400x400_Twitter_logo_white.png :target: http://www.jetbrains.com/ .. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=https://upload.wikimedia.org/wikipedia/en/9/90/PremiumSoft_Navicat_Premium_Logo.png :target: http://www.navicat.com/ .. image:: https://images1-focus-opensocial.googleusercontent.com/gadgets/proxy?container=focus&refresh=3600&resize_h=50&url=https://www.citrix.com/content/dam/citrix/en_us/images/logos/citrix/citrix-logo-black.jpg :target: http://www.citrix.com/ jira-1.0.10/build/000077500000000000000000000000001304741173700136245ustar00rootroot00000000000000jira-1.0.10/build/.gitignore000066400000000000000000000001071304741173700156120ustar00rootroot00000000000000# Ignore everything in this directory * # Except this file !.gitignore jira-1.0.10/dist/000077500000000000000000000000001304741173700134705ustar00rootroot00000000000000jira-1.0.10/dist/.gitignore000066400000000000000000000001071304741173700154560ustar00rootroot00000000000000# Ignore everything in this directory * # Except this file !.gitignore jira-1.0.10/docs/000077500000000000000000000000001304741173700134555ustar00rootroot00000000000000jira-1.0.10/docs/Makefile000066400000000000000000000127431304741173700151240ustar00rootroot00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" clean: -rm -rf $(BUILDDIR)/* html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/JIRAPythonClient.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/JIRAPythonClient.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/JIRAPythonClient" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/JIRAPythonClient" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." jira-1.0.10/docs/_static/000077500000000000000000000000001304741173700151035ustar00rootroot00000000000000jira-1.0.10/docs/_static/.placeholder000066400000000000000000000000001304741173700173540ustar00rootroot00000000000000jira-1.0.10/docs/_static/python-16.png000066400000000000000000000025741304741173700173660ustar00rootroot00000000000000PNG  IHDR(-SgAMA a cHRMz&u0`:pQ<PLTE8{8z7s7r8z7p8y7o8zH7o8I:G8DD:78z8z7x7w7v7u7s7r8z8z8y7w7q7p8y7x7w7v7p7o8z8y7x7w7v7u7t7r6nxDG8z8y7x7w7v7t7s7r7q4lTGED8y7y4kPB7x6jH@7w5q7q>t>s>q>pBqbC>7v6qySPNLJEC<7t;sQ:7s7rFxN:97r7q6oIxLFDB@><:987p/kuJDB@><;98IH?<:9IF<998ECA?=;987v7u7s7r7u7s7r7q7q7o7o6n7w7v7u7s7r7q7o6n6mED7w7v7u7s7r7q7o6n6m5kDB7v7u7sB@7u7sB@>7s7rNLJHFDB@><6pLJHFDB@>/!|tRNS:- Ę " Џݮ+ϣ㬪鿼pr岤"ڒċ  Ď~ $>bKGDQGtIME ^.IDATc```dafaVY9yYUTb +͛`˩oh 풥vN .˖Xj\opgظi۶عko8~pDdTtLl\|‘HN9y_x)!-rF敫׮߸ylܼ KJ+*kj?ohljniO::~} B&O:m" wigF%tEXtdate:create2016-05-02T10:25:05+01:00xlv%tEXtdate:modify2016-05-02T10:25:05+01:00 1IENDB`jira-1.0.10/docs/_static/python-256.png000066400000000000000000000262771304741173700174620ustar00rootroot00000000000000PNG  IHDR\rfgAMA a cHRMz&u0`:pQ<bKGDtIME &5q+IDATxy$GywVh4/ tq!`0kn92]a 66 C0c aeY FH9#z#;+Wdedd>Od|,dUV|Wc- ݱ'8 boxVc øP@o;ۈ؂M؄{p7q]{f;Du06 ~\ [bAx4{ q0^_XbêeZHb;p+nl݌qoۋ(LQ UQl8vj/G' G}OLv8Řf f&Z w7J~Iu&>R(E ΅"p<ptuS=WZ Wq ~E0WλzS uEQk4ݏIx&/2=C\{ag[9 P6|U-ԋbK8O0՜U:| {QC٘[Wк AM2%$\׈PGr`P{yI?Fz!gG{&#|ZyqkdW%hpB៷=wS<(mAIAyjǯaeDŃ88?|KU9 E.;p9tS],?54 ,_.Oǧa;?#rh-~Bzl" s>VPQFVk0K WK䳑@0M!+RnW=a;s+u>^z |Յ#w< 6:;"Mq41OmҴ]+k*ߣ=I6>m?J_jra|Wzbij-+)8 'N;Rj(05̗»LwEi9[/ÓN5Ҩ&dgT_1qe@R{ Jh}EN/m bou+^i H{)Αfʒ;8zyWo7iMx=3O3x4K>* 8pF㯄(eK>}谺تfϮU/$ފ |(!OL>u|߇i߳&>xa;>F\іmVe?1<֝~QNA}Vow<xs۬Ň6 ד09 H/M-Kj_\٭VǫlUgƪiO^y@KUԝ 0gzU͞=SrY?-JThR>$}BK`𓾭:4æ4O}=rH#KV_c-]!ƮV 3cU|{G9P+4N~j{ c8`[هG~Z5z` ڈyC?@X#V9تfόUNm=pq :?I\ZAv4UK[:6Cmr߈`y7uH3gѽZQ-srmEg?<oE?WFJ#] H}=aoC?fҗUS"_tIQP*?:V!< =3 lU؉ ?9E0o J>dQ}W p_l¿ӥi:2U͞=vlgeNի|^->A]EL{?C ϕx2{6f&~8]_IVO_>$5-9jd{iϭWji݅$km9jd磑5`gk7T1 pd5`XQ+[LC]c@4JTe |=@FjQoYPFTfoVLsHʷVgО @GecO!.Eŵg!ϡ(_{rإ߬-m=ʿm%'ÿ`Jq/rh ړ_L@2 C x:Z^ 1E\<72ZKp9`m#Y7f)?J k2rH+J$grj?{D<_GF=mxwu!ѳrλÅ]dؘz^PS+ԥ6o44O|$5_EJ\׾`#n+ǞAi3jjA£/б8^UL-Žug~E]kw˪5:L>I~FWBcagq;qlle'5ő,ksOZnùGO/L>x~Rm;><2wްW#ώϮ3˩oۙ2&WT\auu_N5<$ ^8؉xV)W:2cfO Jҗ_Q5vJų_,`XwZ$^8*_2P5{S ]Ń_Jߐ|v\׬W~/]w P5{^O$nWC43<_}K^X}=1+Lkp{lOq=wXvvJ +߻UgX/Qe6[ZA:omYJ}pZ-/Ugؤ7WԤ~o\}rwfi`݆EQcۯVwf2*S~_qUoA`^`݆P+"U' ( >T͞ sM cqI9+&_NWX}=>J/[feHWgP2U/QO`݆"DYO%fuCA]\ő<",n8_08n\/8GwQM@a t, }=(M7g%,=M P*4 :V>T͞ePwaN05U&m_ևٳ XC ›)jqeqC{*?@f2f*׫g@ 14F3ږT>T͞~88EP5{2mZ}zİfn`и[qpK2jdtx'M:Qsc Ez%--Ї3 G-3\7_8UR@*iҽXU_VC?ZK2*9m]7~NY~ġxu'?Xf0o>e:In ~˜p0|g* g|=~6ܣ' =MC?fj M=fׅ|ަ羑 G G$?Xfw7:i5Nv)}d)rqRXw?6%}u?2gq^}GL {b-*{7o!ٷVR*CY#n·?}k;o'ql*uTP5{ oU\3q;f?'4[OИ'ÿ>T͞.|6.WX~؍8 BP5{F~\S9VpX}=#MSlw ٌpXm{=Iޞ'>^qNFPz*֛/?15rf3U'{_Ez}Nct* 99]\|!ד/ ?"z?3aSZ6ee ?`;o3?wI.ϵI/lvG>T͞v[Kd _눿f5Wv%IbjgaSt# ~~'y >,=:_ I]៦Cêrӯ*?_s~f{aYߍOcSYOaLxp\ Y'}3*m|ɲWv}s_~]~RسebOޞe 6LS M"\)p:]gZf7~:˦.={ Y|K:>kzhWS\ e)M6߻Ko2? p%c&>*^O]e-  ӧ. b{~QEyW~Ao)wvzf+d=ָw.6  ~&}ǍI.TMV`}*A:i_}_ٲ.XxI- >Lm ?{4ҶHRg|q:v`+Nٛ.9Gv} 5 tu~ %k'?V +F\(M9};=4zW\JK#%}*i!;~R DD=.BqOm{Z\5x'^=m&cd`'ZKs}F6V8UVi\Q^7 /5Q?1`h2P7F MDz59qvn/88Mz-/;̳33R ~2 !}H؞_Zտlm7i.g/l}Уi2R/> S\ۘ#ϯ`fnO3p qwdymƥ  eN/+72H㆗H5ƥ~¾kzѿjA+(YqF/S;3N0.=]ݠvi://qé=&ēq4Muʶ듩JTA/ޙ]@i:ˊw?ne((p"%=c {;;0J$.I^ ߇ ?3墨  Nı©4GI,;rg{)q+]Cv 29ҋ&뵜jm=܋pOjlqsǍ/XgNK (^=Ilz1_zο,qK;b·u{8W#E.q޷] b( J*_ԈңO-Ww/.sˬN`}_ں1.=.QX~2JA>+j8W?׍qٽ%V/ѱՀoބUp;00ʾ4'lj=dy_wG_+MAzӟWfedgp@P3iב~ dj. =}uMzMa[o1ׅ+T8CzV<2S>mIR~|A = O{_qtO|dz~GM b+ 8xjsXGz3a3.~T9w֤nJ/{}];S]arg]>]\،J41m[jNsQՃLz^?āܹѸץ3tw?HT7틷J~{-}%}s_5K{?e؎{n NQCnzK9."vvX%Uʤ/N6 fUE #~RgpGax:̥Ck1n+yJ~3no;qKw#~­mSYNY =wz : O|Fͦ~Rc>‰󳷛mT2Zʝ,Som+ep7Q1[8oO&*A+B%}(g}_•ć~Jlɀq+-7tiUgA|A_mm3 X677:YnJХL*]3/|\Ľ we N3f>j=i;%+Y{&qE.^BI.;je@+B?wwn'.!ؘ%6J}8'E]vv4嗥? W •OqpSsi|U]vx 6F7]¨ؤp/.ͱ#VtMO`}0~BqŘ&>JE:;gOFOO 2=f)/4?zbp]wdž v^;YPFۤ`l~dž;рi~'K(&ҕq.Z9#L|!g77GE36CUI |oiPS&=fTkrL6zz@oA@) gWO,y֍^0ӓ@v㌄EaQ U;FA/Zm='l+_75^o2%ʧBMc՟w /rM_美]`#j ;Їm˧=]5_-tlЈi_}ضDJX2\N!l {7#ҷ?۪mȫ ٲL%gVB2-sy|mtm ͥ'ؑ/ibV2Ue Cl?]@j/jא_m @Ocy~?4WWumDNhu?1oeҗkޒա}'q:uMWpIړq!* У3rg+s(dvsLjEp^wg+D㯌딭;?_<*@N⪶Ui!\d.|*wvP_O`⣯fk?zqS 5JP&p=mO0lʷpY?`ĻLV_랷;\K/]_峠׉??=8j Tu{֢0_g%d+V> V|B߬ ~HAu#bfKtf׎ 1][q:q.N(A[k^F$of& ZeZjTrg|ŸSl-`A(-^+$Q&UU܃}kpZeQ_6 gzbɆ @+B>+a(gƯ4p$(nN&zX[o - H3UWK<~7" t_[Xx?n6zt=+ 8 XGpCv4t5&H_IsYfc 8[`kXW]*-҈ۤ!|?^PgkkO?x>q ǡĪe8@ӪFHvT0b%V7~o>Q}x" Ӽ/ MM| kO?6|GG‘x,nҗ:ڗlLlQV1.]`rg_>=>| PvrOϾ Q*d]>ݳdU([2˭|r @4|;_|F@7;;>? ٌ ))߇C@k- E*0WK @sqfPS+nVU nC}ٹP;ͥ-32V>Sda(}볌ʩ"k g3z*\ioӋ#?9(-f2w\ܹu{t]O<0+$c@K\<лw@ht*݌~ܦ 9 O8OWborhjEq.wكcmOs!Eő`{3S3])U>Q<Ч }L;3ٕsmڽ\Dz|C(]6ڑo`JQh.~ g˕OC\,4߈O |'`3K {+rI87.{֝ioMo:ނp'5ihP\ ($w.gmac1f颴?= |@8UbQWnQAi2nSGTV)SY7KO`j4/Peʧ\Vᓸ/'>Y-|3|^ӂڌ?B._IO I/S>:˚Rm@Pdpvxnz3@GKZo ՛+ e9ww1mQ}| \Ej֧7qb˜ _G*ߡןm gcG8WLz<+qBtIO2~,|zT\}2BXMx_ߧS~WW"wQP\^+o%>?v=#WxNqP\u2jGm;s:h) =IW|/$ֵu ǯg[R؞F X !~ ֮3Ҝ _HnXwޕ(|>iSR=wmTQ~4ҨH/)n5+OXIO q#Lssf?wϸD"C?@H+OQwݟ+~ ¿SVF9,d45I}OS'JnQsvYU-[j2 *⹌`Ǫݤcqx4q h- h#$'Qw۱,}>܍S D!;v(!T|盋+C { {I[bOZ/J@1^b]P$Yp Ԃ{ER̠j7~Fdltr-gԄ3N|oeGJ5I͋dM=UHd;Smj DU,qtR ,m;6,yP9IŹ-@A 4'8t27y%@+6zy k2uΫw̕5 d:ydMa WgkRd8#w'HzUi8od8*8z]J,, PJ6#ϒMհڒblA,}Y '1 la25z`ﳻ{&&-"Jࠤ (;;1iu!2Mk =1darC+yr\Fv;'nܻ} 5ywa1uH3e1oN+ZsC8O)IGчЛG,xV}8}5=kI}<3v5gxmub 3_Fʉ^(jtؤCLw_uby x0n@ڹ),!|H>L(,eU )px=H2$4|"f"uE>v:lB34ACVi47JV'1tzp}w%O{貼y0b \ kFzbYO:cyX- 2݂~.GSMR~9{92IB+1=,^9|Ay} xAhN܆@wq}y$ډ~ו/rQj %tEXtdate:create2016-05-02T10:25:05+01:00xlv%tEXtdate:modify2016-05-02T10:25:05+01:00 1IENDB`jira-1.0.10/docs/_static/python-64.png000066400000000000000000000050441304741173700173640ustar00rootroot00000000000000PNG  IHDR@@iqgAMA a cHRMz&u0`:pQ<bKGDtIME ^. (IDATx}U}Y(\!E+V[0 GHAbNAc3JCa:Qôl-dB˓K&~ݻ}GB9w}<[9ҧoX#EEN $HC {IK~'qP]REm}b- \%'@ҒN]wgD'ȨWr'(~Cl8Kɩ0\2K!ggSg.!|Ks`nY^2Z$τ%r-$sI5{-]3;x7ޅ֚$W8'{6@t&P.er4ol&/ߖY# 1/)}> 0zoe~@vx+]=୊Q4: NPm]C&-л̄pFqy} T6o~z@'땶`8\`_j%s~Qt5H}~6DvN1Wؼzۉf1 J._cQ=u32Αogu,Խ;W!\~Buľw;GgzȊO:vI?􋾚;ۚɾɾ4Eu$(5"bYL߸^79 pL̀[BώkW57g4`,VjbXbyr}_uh0_fۭnqq4Rgxfi <\QF?vnFE10|BtPӐEEKFǏčۆvxs'o| Wn_b+ÏkU#۵wϓǫM@1rO@b_*/IRUAL(lu[? PG"B$*dbН>rx/8.b4(J9wwKQv8||3Wq(>B o(?OFzؑB1Cvtn뇟%AgT=-2Ϝn 7uE#!)-[OP&^jM`o^=`;Q&vT\@w3fWJOx{_ m=ՕZ8 k_.~?&ok䇘_1LwQDqgT[:!9}&yX)d~ C%tEXtdate:create2016-05-02T10:25:05+01:00xlv%tEXtdate:modify2016-05-02T10:25:05+01:00 1IENDB`jira-1.0.10/docs/advanced.rst000066400000000000000000000040331304741173700157540ustar00rootroot00000000000000Advanced ******** Resource Objects and Properties =============================== The library distinguishes between two kinds of data in the JIRA REST API: *resources* and *properties*. A *resource* is a REST entity that represents the current state of something that the server owns; for example, the issue called "ABC-123" is a concept managed by JIRA which can be viewed as a resource obtainable at the URL *http://jira-server/rest/api/latest/issue/ABC-123*. All resources have a *self link*: a root-level property called *self* which contains the URL the resource originated from. In jira-python, resources are instances of the *Resource* object (or one of its subclasses) and can only be obtained from the server using the ``find()`` method. Resources may be connected to other resources: the issue *Resource* is connected to a user *Resource* through the ``assignee`` and ``reporter`` fields, while the project *Resource* is connected to a project lead through another user *Resource*. .. important:: A resource is connected to other resources, and the client preserves this connection. In the above example, the object inside the ``issue`` object at ``issue.fields.assignee`` is not just a dict -- it is a full-fledged user *Resource* object. Whenever a resource contains other resources, the client will attempt to convert them to the proper subclass of *Resource*. A *properties object* is a collection of values returned by JIRA in response to some query from the REST API. Their structure is freeform and modeled as a Python dict. Client methods return this structure for calls that do not produce resources. For example, the properties returned from the URL *http://jira-server/rest/api/latest/issue/createmeta* are designed to inform users what fields (and what values for those fields) are required to successfully create issues in the server's projects. Since these properties are determined by JIRA's configuration, they are not resources. The JIRA client's methods document whether they will return a *Resource* or a properties object. jira-1.0.10/docs/api.rst000066400000000000000000000005221304741173700147570ustar00rootroot00000000000000API Documentation ***************** .. module:: jira .. contents:: Contents :local: JIRA ==== .. autoclass:: JIRA Priority ======== .. autoclass:: Priority Comment ======= .. autoclass:: Comment Worklog ======= .. autoclass:: Worklog Watchers ======== .. autoclass:: Watchers JIRAError ========= .. autoclass:: JIRAError jira-1.0.10/docs/conf.py000066400000000000000000000202151304741173700147540ustar00rootroot00000000000000# -*- coding: utf-8 -*- # # JIRA Python Client documentation build configuration file, created by # sphinx-quickstart on Thu May 3 17:01:50 2012. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. import os import sphinx_rtd_theme import sys # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. sys.path.insert(0, os.path.abspath('..')) from jira import __version__ # noqa # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.viewcode', 'sphinx.ext.intersphinx'] intersphinx_mapping = { 'python': ('https://docs.python.org/3.5', None), 'requests': ('http://docs.python-requests.org/en/latest/', None), 'requests-oauthlib': ('https://requests-oauthlib.readthedocs.io/en/latest/', None), 'ipython': ('http://ipython.readthedocs.io/en/stable/', None), 'pip': ('http://pip.readthedocs.io/en/stable/', None), } autodoc_default_flags = ['members', 'undoc-members', 'show-inheritance'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix of source filenames. source_suffix = '.rst' # The encoding of source files. # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'jira-python' copyright = u'2012, Atlassian Pty Ltd.' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = __version__ # The full version, including alpha/beta/rc tags. release = __version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # today = '' # Else, today_fmt is used as the format for a strftime call. # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns = ['_build'] # The reST default role (used for this markup: `text`) to use for all documents. # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". # html_title = None # A shorter title for the navigation bar. Default is the same as html_title. # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. # html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # html_additional_pages = {} # If false, no module index is generated. # html_domain_indices = True # If false, no index is generated. # html_use_index = True # If true, the index is split into individual pages for each letter. html_split_index = False # If true, links to the reST sources are added to the pages. html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). html_file_suffix = None # Output file base name for HTML help builder. htmlhelp_basename = 'jirapythondoc' # -- Options for LaTeX output -------------------------------------------------- latex_elements = { 'papersize': 'a4paper', 'pointsize': '10pt'} # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ ('index', 'jirapython.tex', u'jira-python Documentation', u'Atlassian Pty Ltd.', 'manual')] # The name of an image file (relative to this directory) to place at the top of # the title page. # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # latex_use_parts = False # If true, show page references after internal links. # latex_show_pagerefs = False # If true, show URL addresses after external links. # latex_show_urls = False # Documents to append as an appendix to all manuals. # latex_appendices = [] # If false, no module index is generated. # latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ ('index', 'jirapython', u'jira-python Documentation', [u'Atlassian Pty Ltd.'], 1)] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ ('index', 'jirapython', u'jira-python Documentation', u'Atlassian Pty Ltd.', 'jirapython', 'One line description of project.', 'Miscellaneous')] # Documents to append as an appendix to all manuals. # texinfo_appendices = [] # If false, no module index is generated. # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # texinfo_show_urls = 'footnote' jira-1.0.10/docs/contributing.rst000066400000000000000000000011771304741173700167240ustar00rootroot00000000000000Contributing ************ The client is an open source project under the BSD license. Contributions of any kind are welcome! https://github.com/pycontribs/jira/ If you find a bug or have an idea for a useful feature, file it at that bitbucket project. Extra points for source code patches -- fork and send a pull request. Discussion and support ====================== We encourage all who wish to discuss by using https://answers.atlassian.com/questions/topics/754366/jira-python Keep in mind to use the jira-python tag when you add a new question. This will assure that the project mantainers will get notified about your question. jira-1.0.10/docs/examples.rst000066400000000000000000000275711304741173700160410ustar00rootroot00000000000000Examples ******** .. contents:: Contents :local: Here's a quick usage example: .. literalinclude:: ../examples/basic_use.py Another example shows how to authenticate with your JIRA username and password: .. literalinclude:: ../examples/basic_auth.py This example shows how to work with GreenHopper: .. literalinclude:: ../examples/greenhopper.py Quickstart ========== Initialization -------------- Everything goes through the JIRA object, so make one:: from jira import JIRA jira = JIRA() This connects to a JIRA started on your local machine at http://localhost:2990/jira, which not coincidentally is the default address for a JIRA instance started from the Atlassian Plugin SDK. You can manually set the JIRA server to use:: jac = JIRA('https://jira.atlassian.com') Authentication -------------- At initialization time, jira-python can optionally create an HTTP BASIC or use OAuth 1.0a access tokens for user authentication. These sessions will apply to all subsequent calls to the JIRA object. The library is able to load the credentials from inside the ~/.netrc file, so put them there instead of keeping them in your source code. HTTP BASIC ^^^^^^^^^^ Pass a tuple of (username, password) to the ``basic_auth`` constructor argument:: authed_jira = JIRA(basic_auth=('username', 'password')) OAuth ^^^^^ Pass a dict of OAuth properties to the ``oauth`` constructor argument:: # all values are samples and won't work in your code! key_cert_data = None with open(key_cert, 'r') as key_cert_file: key_cert_data = key_cert_file.read() oauth_dict = { 'access_token': 'd87f3hajglkjh89a97f8', 'access_token_secret': 'a9f8ag0ehaljkhgeds90', 'consumer_key': 'jira-oauth-consumer', 'key_cert': key_cert_data } authed_jira = JIRA(oauth=oauth_dict) .. note :: The OAuth access tokens must be obtained and authorized ahead of time through the standard OAuth dance. For interactive use, ``jirashell`` can perform the dance with you if you don't already have valid tokens. .. note :: OAuth in Jira uses RSA-SHA1 which requires the PyCrypto library. PyCrypto is **not** installed automatically when installing jira-python. See also the :ref:`Dependencies`. section above. * The access token and token secret uniquely identify the user. * The consumer key must match the OAuth provider configured on the JIRA server. * The key cert data must be the private key that matches the public key configured on the JIRA server's OAuth provider. See https://confluence.atlassian.com/display/JIRA/Configuring+OAuth+Authentication+for+an+Application+Link for details on configuring an OAuth provider for JIRA. Kerberos ^^^^^^^^ To enable Kerberos auth, set ``kerberos=True``:: authed_jira = JIRA(kerberos=True) .. _jirashell-label: Issues ------ Issues are objects. You get hold of them through the JIRA object:: issue = jira.issue('JRA-1330') Issue JSON is marshaled automatically and used to augment the returned Issue object, so you can get direct access to fields:: summary = issue.fields.summary # 'Field level security permissions' votes = issue.fields.votes.votes # 440 (at least) If you only want a few specific fields, save time by asking for them explicitly:: issue = jira.issue('JRA-1330', fields='summary,comment') Reassign an issue:: # requires issue assign permission, which is different from issue editing permission! jira.assign_issue(issue, 'newassignee') Creating issues is easy:: new_issue = jira.create_issue(project='PROJ_key_or_id', summary='New issue from jira-python', description='Look into this one', issuetype={'name': 'Bug'}) Or you can use a dict:: issue_dict = { 'project': {'id': 123}, 'summary': 'New issue from jira-python', 'description': 'Look into this one', 'issuetype': {'name': 'Bug'}, } new_issue = jira.create_issue(fields=issue_dict) You can even bulk create multiple issues:: issue_list = [ { 'project': {'id': 123}, 'summary': 'First issue of many', 'description': 'Look into this one', 'issuetype': {'name': 'Bug'}, }, { 'project': {'key': 'FOO'}, 'summary': 'Second issue', 'description': 'Another one', 'issuetype': {'name': 'Bug'}, }, { 'project': {'name': 'Bar'}, 'summary': 'Last issue', 'description': 'Final issue of batch.', 'issuetype': {'name': 'Bug'}, }] issues = jira.create_issues(field_list=issue_list) .. note:: Project, summary, description and issue type are always required when creating issues. Your JIRA may require additional fields for creating issues; see the ``jira.createmeta`` method for getting access to that information. .. note:: Using bulk create will not throw an exception for a failed issue creation. It will return a list of dicts that each contain a possible error signature if that issue had invalid fields. Successfully created issues will contain the issue object as a value of the ``issue`` key. You can also update an issue's fields with keyword arguments:: issue.update(summary='new summary', description='A new summary was added') issue.update(assignee={'name': 'new_user'}) # reassigning in update requires issue edit permission or with a dict of new field values:: issue.update(fields={'summary': 'new summary', 'description': 'A new summary was added'}) You can suppress notifications:: issue.update(notify=False, description='A quiet description change was made') and when you're done with an issue, you can send it to the great hard drive in the sky:: issue.delete() Updating components:: existingComponents = [] for component in issue.fields.components: existingComponents.append({"name" : component.name}) issue.update(fields={"components": existingComponents}) Fields ------ issue.fields.worklogs # list of Worklog objects issue.fields.worklogs[0].author issue.fields.worklogs[0].comment issue.fields.worklogs[0].created issue.fields.worklogs[0].id issue.fields.worklogs[0].self issue.fields.worklogs[0].started issue.fields.worklogs[0].timeSpent issue.fields.worklogs[0].timeSpentSeconds issue.fields.worklogs[0].updateAuthor # dictionary issue.fields.worklogs[0].updated issue.fields.timetracking.remainingEstimate # may be NULL or string ("0m", "2h"...) issue.fields.timetracking.remainingEstimateSeconds # may be NULL or integer issue.fields.timetracking.timeSpent # may be NULL or string issue.fields.timetracking.timeSpentSeconds # may be NULL or integer Searching --------- Leverage the power of `JQL `_ to quickly find the issues you want:: issues_in_proj = jira.search_issues('project=PROJ') all_proj_issues_but_mine = jira.search_issues('project=PROJ and assignee != currentUser()') # my top 5 issues due by the end of the week, ordered by priority oh_crap = jira.search_issues('assignee = currentUser() and due < endOfWeek() order by priority desc', maxResults=5) # Summaries of my last 3 reported issues print [issue.fields.summary for issue in jira.search_issues('reporter = currentUser() order by created desc', maxResults=3)] Comments -------- Comments, like issues, are objects. Get at issue comments through the parent Issue object or the JIRA object's dedicated method:: comments_a = issue.fields.comment.comments comments_b = jira.comments(issue) # comments_b == comments_a Get an individual comment if you know its ID:: comment = jira.comment('JRA-1330', '10234') Adding, editing and deleting comments is similarly straightforward:: comment = jira.add_comment('JRA-1330', 'new comment') # no Issue object required comment = jira.add_comment(issue, 'new comment', visibility={'type': 'role', 'value': 'Administrators'}) # for admins only comment.update(body = 'updated comment body') comment.delete() Transitions ----------- Learn what transitions are available on an issue:: issue = jira.issue('PROJ-1') transitions = jira.transitions(issue) [(t['id'], t['name']) for t in transitions] # [(u'5', u'Resolve Issue'), (u'2', u'Close Issue')] .. note:: Only the transitions available to the currently authenticated user will be returned! Then perform a transition on an issue:: # Resolve the issue and assign it to 'pm_user' in one step jira.transition_issue(issue, '5', assignee={'name': 'pm_user'}, resolution={'id': '3'}) # The above line is equivalent to: jira.transition_issue(issue, '5', fields: {'assignee':{'name': 'pm_user'}, 'resolution':{'id': '3'}}) Projects -------- Projects are objects, just like issues:: projects = jira.projects() Also, just like issue objects, project objects are augmented with their fields:: jra = jira.project('JRA') print(jra.name) # 'JIRA' print(jra.lead.displayName) # 'Paul Slade [Atlassian]' It's no trouble to get the components, versions or roles either (assuming you have permission):: components = jira.project_components(jra) [c.name for c in components] # 'Accessibility', 'Activity Stream', 'Administration', etc. jira.project_roles(jra) # 'Administrators', 'Developers', etc. versions = jira.project_versions(jra) [v.name for v in reversed(versions)] # '5.1.1', '5.1', '5.0.7', '5.0.6', etc. Watchers -------- Watchers are objects, represented by :class:`jira.resources.Watchers`:: watcher = jira.watchers(issue) print("Issue has {} watcher(s)".format(watcher.watchCount)) for watcher in watcher.watchers: print(watcher) # watcher is instance of jira.resources.User: print(watcher.emailAddress) You can add users to watchers by their name:: jira.add_watcher(issue, 'username') jira.add_watcher(issue, user_resource.name) And of course you can remove users from watcher:: jira.remove_watcher(issue, 'username') jira.remove_watcher(issue, user_resource.name) Attachments ----------- Attachments let user add files to issues. First you'll need an issue to which the attachment will be uploaded. Next, you'll need file itself, that is going to be attachment. File could be file-like object or string, representing path on local machine. Also you can select final name of the attachment if you don't like original. Here are some examples:: # upload file from `/some/path/attachment.txt` jira.add_attachment(issue=issue, attachment='/some/path/attachment.txt') # read and upload a file (note binary mode for opening, it's important): with open('/some/path/attachment.txt', 'rb') as f: jira.add_attachment(issue=issue, attachment=f) # attach file from memory (you can skip IO operations). In this case you MUST provide `filename`. import StringIO attachment = StringIO.StringIO() attachment.write(data) jira.add_attachment(issue=issue, attachment=attachment, filename='content.txt') If you would like to list all available attachment, you can do it with through attachment field:: for attachment in issue.fields.attachment: print("Name: '{filename}', size: {size}".format( filename=attachment.filename, size=attachment.size)) # to read content use `get` method: print("Content: '{}'".format(attachment.get())) You can delete attachment by id:: # Find issues with attachments: query = jira.search_issues(jql_str="attachments is not EMPTY", json_result=True, fields="key, attachment") # And remove attachments one by one for i in query['issues']: for a in i['fields']['attachment']: print("For issue {0}, found attach: '{1}' [{2}].".format(i['key'], a['filename'], a['id'])) jira.delete_attachment(a['id']) jira-1.0.10/docs/extra/000077500000000000000000000000001304741173700146005ustar00rootroot00000000000000jira-1.0.10/docs/extra/jira.xml000066400000000000000000000001641304741173700162500ustar00rootroot00000000000000 latest https://jira.readthedocs.io/en/latest/docset/jira.tgz jira-1.0.10/docs/index.rst000066400000000000000000000020361304741173700153170ustar00rootroot00000000000000.. jira-python documentation master file, created by sphinx-quickstart on Thu May 3 17:01:50 2012. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Python JIRA ########### Python library to work with JIRA APIs .. toctree:: :numbered: installation examples jirashell advanced contributing api This documents the ``jira`` python package (version |release|), a Python library designed to ease the use of the JIRA REST API. Some basic support for the GreenHopper REST API also exists. Documentation is also available in `Dash `_ format. The source is stored at https://github.com/pycontribs/jira. Until someone will find a better way to generate the release notes you can read https://github.com/pycontribs/jira/blob/master/CHANGELOG which is generated based on git commit messages. Indices and tables ****************** * :ref:`genindex` * :ref:`modindex` * :ref:`search` jira-1.0.10/docs/installation.rst000066400000000000000000000042361304741173700167150ustar00rootroot00000000000000Installation ************ .. contents:: Contents :local: The easiest (and best) way to install jira-python is through `pip `_:: $ pip install jira This will handle the client itself as well as the requirements. If you're going to run the client standalone, we strongly recommend using a `virtualenv `_, which pip can also set up for you:: $ pip -E jira_python install jira $ workon jira_python Doing this creates a private Python "installation" that you can freely upgrade, degrade or break without putting the critical components of your system at risk. Source packages are also available at PyPI: https://pypi.python.org/pypi/jira/ .. _Dependencies: Dependencies ============ Python 2.7 and Python 3.x are both supported. - :py:mod:`requests` - Kenneth Reitz's indispensable `python-requests `_ library handles the HTTP business. Usually, the latest version available at time of release is the minimum version required; at this writing, that version is 1.2.0, but any version >= 1.0.0 should work. - :py:mod:`requests-oauthlib` - Used to implement OAuth. The latest version as of this writing is 0.3.3. - :py:mod:`requests-kerberos` - Used to implement Kerberos. - :py:mod:`ipython` - The `IPython enhanced Python interpreter `_ provides the fancy chrome used by :ref:`jirashell-label`. - :py:mod:`filemagic` - This library handles content-type autodetection for things like image uploads. This will only work on a system that provides libmagic; Mac and Unix will almost always have it preinstalled, but Windows users will have to use Cygwin or compile it natively. If your system doesn't have libmagic, you'll have to manually specify the ``contentType`` parameter on methods that take an image object, such as project and user avater creation. - :py:mod:`pycrypto` - This is required for the RSA-SHA1 used by OAuth. Please note that it's **not** installed automatically, since it's a fairly cumbersome process in Windows. On Linux and OS X, a ``pip install pycrypto`` should do it. Installing through :py:mod:`pip` takes care of these dependencies for you. jira-1.0.10/docs/jirashell.rst000066400000000000000000000064711304741173700161740ustar00rootroot00000000000000jirashell ********* There is no substitute for play. The only way to really know a service, an API or a package is to explore it, poke at it, and bang your elbows -- trial and error. A REST design is especially well-suited to active exploration, and the ``jirashell`` script (installed automatically when you use pip) is designed to help you do exactly that. Run it from the command line:: $ jirashell -s http://jira.atlassian.com *** JIRA shell active; client is in 'jira'. Press Ctrl-D to exit. In [1]: This is a specialized Python interpreter (built on IPython) that lets you explore JIRA as a service. Any legal Python code is acceptable input. The shell builds a JIRA client object for you (based on the launch parameters) and stores it in the ``jira`` object. Try getting an issue:: In [1]: issue = jira.issue('JRA-1330') ``issue`` now contains a reference to an issue ``Resource``. To see the available properties and methods, hit the TAB key:: In [2]: issue. issue.delete issue.fields issue.id issue.raw issue.update issue.expand issue.find issue.key issue.self In [2]: issue.fields. issue.fields.aggregateprogress issue.fields.customfield_11531 issue.fields.aggregatetimeestimate issue.fields.customfield_11631 issue.fields.aggregatetimeoriginalestimate issue.fields.customfield_11930 issue.fields.aggregatetimespent issue.fields.customfield_12130 issue.fields.assignee issue.fields.customfield_12131 issue.fields.attachment issue.fields.description issue.fields.comment issue.fields.environment issue.fields.components issue.fields.fixVersions issue.fields.created issue.fields.issuelinks issue.fields.customfield_10150 issue.fields.issuetype issue.fields.customfield_10160 issue.fields.labels issue.fields.customfield_10161 issue.fields.mro issue.fields.customfield_10180 issue.fields.progress issue.fields.customfield_10230 issue.fields.project issue.fields.customfield_10575 issue.fields.reporter issue.fields.customfield_10610 issue.fields.resolution issue.fields.customfield_10650 issue.fields.resolutiondate issue.fields.customfield_10651 issue.fields.status issue.fields.customfield_10680 issue.fields.subtasks issue.fields.customfield_10723 issue.fields.summary issue.fields.customfield_11130 issue.fields.timeestimate issue.fields.customfield_11230 issue.fields.timeoriginalestimate issue.fields.customfield_11431 issue.fields.timespent issue.fields.customfield_11433 issue.fields.updated issue.fields.customfield_11434 issue.fields.versions issue.fields.customfield_11435 issue.fields.votes issue.fields.customfield_11436 issue.fields.watches issue.fields.customfield_11437 issue.fields.workratio Since the *Resource* class maps the server's JSON response directly into a Python object with attribute access, you can see exactly what's in your resources. jira-1.0.10/docs/templates/000077500000000000000000000000001304741173700154535ustar00rootroot00000000000000jira-1.0.10/docs/templates/.placeholder000066400000000000000000000000001304741173700177240ustar00rootroot00000000000000jira-1.0.10/examples/000077500000000000000000000000001304741173700143435ustar00rootroot00000000000000jira-1.0.10/examples/basic_auth.py000066400000000000000000000015041304741173700170170ustar00rootroot00000000000000# This script shows how to connect to a JIRA instance with a # username and password over HTTP BASIC authentication. from collections import Counter from jira import JIRA # By default, the client will connect to a JIRA instance started from the Atlassian Plugin SDK. # See # https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK # for details. jira = JIRA(basic_auth=('admin', 'admin')) # a username/password tuple # Get the mutable application properties for this server (requires # jira-system-administrators permission) props = jira.application_properties() # Find all issues reported by the admin issues = jira.search_issues('assignee=admin') # Find the top three projects containing issues reported by admin top_three = Counter( [issue.fields.project.key for issue in issues]).most_common(3) jira-1.0.10/examples/basic_use.py000066400000000000000000000033201304741173700166500ustar00rootroot00000000000000# This script shows how to use the client in anonymous mode # against jira.atlassian.com. from jira import JIRA import re # By default, the client will connect to a JIRA instance started from the Atlassian Plugin SDK # (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details). # Override this with the options parameter. options = { 'server': 'https://jira.atlassian.com'} jira = JIRA(options) # Get all projects viewable by anonymous users. projects = jira.projects() # Sort available project keys, then return the second, third, and fourth keys. keys = sorted([project.key for project in projects])[2:5] # Get an issue. issue = jira.issue('JRA-1330') # Find all comments made by Atlassians on this issue. atl_comments = [comment for comment in issue.fields.comment.comments if re.search(r'@atlassian.com$', comment.author.emailAddress)] # Add a comment to the issue. jira.add_comment(issue, 'Comment text') # Change the issue's summary and description. issue.update( summary="I'm different!", description='Changed the summary to be different.') # Change the issue without sending updates issue.update(notify=False, description='Quiet summary update.') # You can update the entire labels field like this issue.update(labels=['AAA', 'BBB']) # Or modify the List of existing labels. The new label is unicode with no # spaces issue.fields.labels.append(u'new_text') issue.update(fields={"labels": issue.fields.labels}) # Send the issue away for good. issue.delete() # Linking a remote jira issue (needs applinks to be configured to work) issue = jira.issue('JRA-1330') issue2 = jira.issue('XX-23') # could also be another instance jira.add_remote_link(issue, issue2) jira-1.0.10/examples/greenhopper.py000066400000000000000000000017651304741173700172440ustar00rootroot00000000000000# This script shows how to use the client in anonymous mode # against jira.atlassian.com. from jira.client import GreenHopper # By default, the client will connect to a JIRA instance started from the Atlassian Plugin SDK # (see https://developer.atlassian.com/display/DOCS/Installing+the+Atlassian+Plugin+SDK for details). # Override this with the options parameter. # GreenHopper is a plugin in a JIRA instance options = { 'server': 'https://jira.atlassian.com'} gh = GreenHopper(options) # Get all boards viewable by anonymous users. boards = gh.boards() # Get the sprints in a specific board board_id = 441 print("GreenHopper board: %s (%s)" % (boards[0].name, board_id)) sprints = gh.sprints(board_id) # List the incomplete issues in each sprint for sprint in sprints: sprint_id = sprint.id print("Sprint: %s" % sprint.name) incompleted_issues = gh.incompleted_issues(board_id, sprint_id) print("Incomplete issues: %s" % ', '.join(issue.key for issue in incompleted_issues)) jira-1.0.10/hooks/000077500000000000000000000000001304741173700136505ustar00rootroot00000000000000jira-1.0.10/hooks/pre-commit000077500000000000000000000001501304741173700156460ustar00rootroot00000000000000#!/bin/sh set -e python -m autopep8 --in-place jira/*.py setup.py tests/*.py examples/*.py --recursive jira-1.0.10/jira/000077500000000000000000000000001304741173700134525ustar00rootroot00000000000000jira-1.0.10/jira/__init__.py000066400000000000000000000017141304741173700155660ustar00rootroot00000000000000# -*- coding: utf-8 -*- """The root of JIRA package namespace.""" from __future__ import unicode_literals from pbr.version import VersionInfo _v = VersionInfo('jira').semantic_version() __version__ = _v.release_string() version_info = _v.version_tuple() from jira.client import Comment # noqa: E402 from jira.client import Issue # noqa: E402 from jira.client import JIRA # noqa: E402 from jira.client import Priority # noqa: E402 from jira.client import Project # noqa: E402 from jira.client import Role # noqa: E402 from jira.client import User # noqa: E402 from jira.client import Watchers # noqa: E402 from jira.client import Worklog # noqa: E402 from jira.config import get_jira # noqa: E402 from jira.exceptions import JIRAError # noqa: E402 __all__ = ( 'Comment', '__version__', 'Issue', 'JIRA', 'JIRAError', 'Priority', 'Project', 'Role', 'User', 'version_info', 'Watchers', 'Worklog', 'get_jira' ) jira-1.0.10/jira/client.py000066400000000000000000003752351304741173700153210ustar00rootroot00000000000000#!/usr/bin/python # -*- coding: utf-8 -*- from __future__ import unicode_literals from __future__ import print_function """ This module implements a friendly (well, friendlier) interface between the raw JSON responses from JIRA and the Resource/dict abstractions provided by this library. Users will construct a JIRA object as described below. Full API documentation can be found at: https://jira-python.readthedocs.org/en/latest/ """ from functools import wraps import imghdr import mimetypes import collections import copy import json import logging import os import re import tempfile try: # Python 2.7+ from logging import NullHandler except ImportError: class NullHandler(logging.Handler): def emit(self, record): pass import calendar import datetime import hashlib from numbers import Number import requests import sys import time import warnings from requests.utils import get_netrc_auth from six import iteritems from six.moves.urllib.parse import urlparse # JIRA specific resources from jira.resources import * # NOQA # GreenHopper specific resources from jira.exceptions import JIRAError from jira.resilientsession import raise_on_error from jira.resilientsession import ResilientSession from jira.resources import Board from jira.resources import GreenHopperResource from jira.resources import Sprint from jira import __version__ from jira.utils import CaseInsensitiveDict from jira.utils import json_loads from jira.utils import threaded_requests from pkg_resources import parse_version try: from collections import OrderedDict except ImportError: # noinspection PyUnresolvedReferences from ordereddict import OrderedDict from six import integer_types from six import string_types # six.moves does not play well with pyinstaller, see https://github.com/pycontribs/jira/issues/38 # from six.moves import html_parser if sys.version_info < (3, 0, 0): import HTMLParser as html_parser else: import html.parser as html_parser try: # noinspection PyUnresolvedReferences from requests_toolbelt import MultipartEncoder except ImportError: pass try: from requests_jwt import JWTAuth except ImportError: pass # warnings.simplefilter('default') # encoding = sys.getdefaultencoding() # if encoding != 'UTF8': # warnings.warning("Python default encoding is '%s' instead of 'UTF8' " \ # "which means that there is a big change of having problems. " \ # "Possible workaround http://stackoverflow.com/a/17628350/99834" % encoding) logging.getLogger('jira').addHandler(NullHandler()) def translate_resource_args(func): """Decorator that converts Issue and Project resources to their keys when used as arguments.""" @wraps(func) def wrapper(*args, **kwargs): arg_list = [] for arg in args: if isinstance(arg, (Issue, Project)): arg_list.append(arg.key) else: arg_list.append(arg) result = func(*arg_list, **kwargs) return result return wrapper def _get_template_list(data): template_list = [] if 'projectTemplates' in data: template_list = data['projectTemplates'] elif 'projectTemplatesGroupedByType' in data: for group in data['projectTemplatesGroupedByType']: template_list.extend(group['projectTemplates']) return template_list def _field_worker(fields=None, **fieldargs): if fields is not None: return {'fields': fields} return {'fields': fieldargs} class ResultList(list): def __init__(self, iterable=None, _startAt=None, _maxResults=None, _total=None, _isLast=None): if iterable is not None: list.__init__(self, iterable) else: list.__init__(self) self.startAt = _startAt self.maxResults = _maxResults # Optional parameters: self.isLast = _isLast self.total = _total class QshGenerator(object): def __init__(self, context_path): self.context_path = context_path def __call__(self, req): parse_result = urlparse(req.url) path = parse_result.path[len(self.context_path):] if len(self.context_path) > 1 else parse_result.path query = '&'.join(sorted(parse_result.query.split("&"))) qsh = '%(method)s&%(path)s&%(query)s' % {'method': req.method.upper(), 'path': path, 'query': query} return hashlib.sha256(qsh).hexdigest() class JIRA(object): """User interface to JIRA. Clients interact with JIRA by constructing an instance of this object and calling its methods. For addressable resources in JIRA -- those with "self" links -- an appropriate subclass of :py:class:`Resource` will be returned with customized ``update()`` and ``delete()`` methods, along with attribute access to fields. This means that calls of the form ``issue.fields.summary`` will be resolved into the proper lookups to return the JSON value at that mapping. Methods that do not return resources will return a dict constructed from the JSON response or a scalar value; see each method's documentation for details on what that method returns. """ DEFAULT_OPTIONS = { "server": "http://localhost:2990/jira", "context_path": "/", "rest_path": "api", "rest_api_version": "2", "agile_rest_path": GreenHopperResource.GREENHOPPER_REST_PATH, "agile_rest_api_version": "1.0", "verify": True, "resilient": True, "async": False, "client_cert": None, "check_update": False, "headers": { 'Cache-Control': 'no-cache', # 'Accept': 'application/json;charset=UTF-8', # default for REST 'Content-Type': 'application/json', # ;charset=UTF-8', # 'Accept': 'application/json', # default for REST # 'Pragma': 'no-cache', # 'Expires': 'Thu, 01 Jan 1970 00:00:00 GMT' 'X-Atlassian-Token': 'no-check'}} checked_version = False # TODO(ssbarnea): remove these two variables and use the ones defined in resources JIRA_BASE_URL = Resource.JIRA_BASE_URL AGILE_BASE_URL = GreenHopperResource.AGILE_BASE_URL def __init__(self, server=None, options=None, basic_auth=None, oauth=None, jwt=None, kerberos=False, validate=False, get_server_info=True, async=False, logging=True, max_retries=3, proxies=None, timeout=None): """Construct a JIRA client instance. Without any arguments, this client will connect anonymously to the JIRA instance started by the Atlassian Plugin SDK from one of the 'atlas-run', ``atlas-debug``, or ``atlas-run-standalone`` commands. By default, this instance runs at ``http://localhost:2990/jira``. The ``options`` argument can be used to set the JIRA instance to use. Authentication is handled with the ``basic_auth`` argument. If authentication is supplied (and is accepted by JIRA), the client will remember it for subsequent requests. For quick command line access to a server, see the ``jirashell`` script included with this distribution. The easiest way to instantiate is using j = JIRA("https://jira.atlasian.com") :param options: Specify the server and properties this client will use. Use a dict with any of the following properties: * server -- the server address and context path to use. Defaults to ``http://localhost:2990/jira``. * rest_path -- the root REST path to use. Defaults to ``api``, where the JIRA REST resources live. * rest_api_version -- the version of the REST resources under rest_path to use. Defaults to ``2``. * agile_rest_path - the REST path to use for JIRA Agile requests. Defaults to ``greenhopper`` (old, private API). Check `GreenHopperResource` for other supported values. * verify -- Verify SSL certs. Defaults to ``True``. * client_cert -- a tuple of (cert,key) for the requests library for client side SSL * check_update -- Check whether using the newest python-jira library version. :param basic_auth: A tuple of username and password to use when establishing a session via HTTP BASIC authentication. :param oauth: A dict of properties for OAuth authentication. The following properties are required: * access_token -- OAuth access token for the user * access_token_secret -- OAuth access token secret to sign with the key * consumer_key -- key of the OAuth application link defined in JIRA * key_cert -- private key file to sign requests with (should be the pair of the public key supplied to JIRA in the OAuth application link) :param kerberos: If true it will enable Kerberos authentication. :param jwt: A dict of properties for JWT authentication supported by Atlassian Connect. The following properties are required: * secret -- shared secret as delivered during 'installed' lifecycle event (see https://developer.atlassian.com/static/connect/docs/latest/modules/lifecycle.html for details) * payload -- dict of fields to be inserted in the JWT payload, e.g. 'iss' Example jwt structure: ``{'secret': SHARED_SECRET, 'payload': {'iss': PLUGIN_KEY}}`` :param validate: If true it will validate your credentials first. Remember that if you are accesing JIRA as anononymous it will fail to instanciate. :param get_server_info: If true it will fetch server version info first to determine if some API calls are available. :param async: To enable async requests for those actions where we implemented it, like issue update() or delete(). :param timeout: Set a read/connect timeout for the underlying calls to JIRA (default: None) Obviously this means that you cannot rely on the return code when this is enabled. """ # force a copy of the tuple to be used in __del__() because # sys.version_info could have already been deleted in __del__() self.sys_version_info = tuple([i for i in sys.version_info]) if options is None: options = {} if server and hasattr(server, 'keys'): warnings.warn( "Old API usage, use JIRA(url) or JIRA(options={'server': url}, when using dictionary always use named parameters.", DeprecationWarning) options = server server = None if server: options['server'] = server if async: options['async'] = async self.logging = logging self._options = copy.copy(JIRA.DEFAULT_OPTIONS) self._options.update(options) self._rank = None # Rip off trailing slash since all urls depend on that if self._options['server'].endswith('/'): self._options['server'] = self._options['server'][:-1] context_path = urlparse(self._options['server']).path if len(context_path) > 0: self._options['context_path'] = context_path self._try_magic() if oauth: self._create_oauth_session(oauth, timeout) elif basic_auth: self._create_http_basic_session(*basic_auth, timeout=timeout) self._session.headers.update(self._options['headers']) elif jwt: self._create_jwt_session(jwt, timeout) elif kerberos: self._create_kerberos_session(timeout) else: verify = self._options['verify'] self._session = ResilientSession(timeout=timeout) self._session.verify = verify self._session.headers.update(self._options['headers']) self._session.max_retries = max_retries if proxies: self._session.proxies = proxies if validate: # This will raise an Exception if you are not allowed to login. # It's better to fail faster than later. user = self.session() if user.raw is None: auth_method = ( oauth or basic_auth or jwt or kerberos or "anonymous" ) raise JIRAError("Can not log in with %s" % str(auth_method)) self.deploymentType = None if get_server_info: # We need version in order to know what API calls are available or not si = self.server_info() try: self._version = tuple(si['versionNumbers']) except Exception as e: logging.error("invalid server_info: %s", si) raise e self.deploymentType = si.get('deploymentType') else: self._version = (0, 0, 0) if self._options['check_update'] and not JIRA.checked_version: self._check_update_() JIRA.checked_version = True self._fields = {} for f in self.fields(): if 'clauseNames' in f: for name in f['clauseNames']: self._fields[name] = f['id'] def _check_update_(self): """Check if the current version of the library is outdated.""" try: data = requests.get("https://pypi.python.org/pypi/jira/json", timeout=2.001).json() released_version = data['info']['version'] if parse_version(released_version) > parse_version(__version__): warnings.warn( "You are running an outdated version of JIRA Python %s. Current version is %s. Do not file any bugs against older versions." % ( __version__, released_version)) except requests.RequestException: pass except Exception as e: logging.warning(e) def __del__(self): """Destructor for JIRA instance.""" session = getattr(self, "_session", None) if session is not None: if self.sys_version_info < (3, 4, 0): # workaround for https://github.com/kennethreitz/requests/issues/2303 try: session.close() except TypeError: # TypeError: "'NoneType' object is not callable" # Could still happen here because other references are also # in the process to be torn down, see warning section in # https://docs.python.org/2/reference/datamodel.html#object.__del__ pass def _check_for_html_error(self, content): # JIRA has the bad habbit of returning errors in pages with 200 and # embedding the error in a huge webpage. if '' in content: logging.warning("Got SecurityTokenMissing") raise JIRAError("SecurityTokenMissing: %s" % content) return False return True def _fetch_pages(self, item_type, items_key, request_path, startAt=0, maxResults=50, params=None, base=JIRA_BASE_URL): """Fetch pages. :param item_type: Type of single item. ResultList of such items will be returned. :param items_key: Path to the items in JSON returned from server. Set it to None, if response is an array, and not a JSON object. :param request_path: path in request URL :param startAt: index of the first record to be fetched :param maxResults: Maximum number of items to return. If maxResults evaluates as False, it will try to get all items in batches. :param params: Params to be used in all requests. Should not contain startAt and maxResults, as they will be added for each request created from this function. :param base: base URL :return: ResultList """ page_params = params.copy() if params else {} if startAt: page_params['startAt'] = startAt if maxResults: page_params['maxResults'] = maxResults try: resource = self._get_json(request_path, params=page_params, base=base) next_items_page = [item_type(self._options, self._session, raw_issue_json) for raw_issue_json in (resource[items_key] if items_key else resource)] except KeyError as e: # improving the error text so we know why it happened raise KeyError(str(e) + " : " + json.dumps(resource)) items = next_items_page if True: # isinstance(resource, dict): if isinstance(resource, dict): total = resource.get('total') # 'isLast' is the optional key added to responses in JIRA Agile 6.7.6. So far not used in basic JIRA API. is_last = resource.get('isLast', True) start_at_from_response = resource.get('startAt', 0) max_results_from_response = resource.get('maxResults', 1) else: # if is a list total = 1 is_last = True start_at_from_response = 0 max_results_from_response = 1 # If maxResults evaluates as False, get all items in batches if not maxResults: page_size = max_results_from_response or len(items) page_start = (startAt or start_at_from_response or 0) + page_size while not is_last and (total is None or page_start < total) and len(next_items_page) == page_size: page_params['startAt'] = page_start page_params['maxResults'] = page_size resource = self._get_json(request_path, params=page_params, base=base) if resource: try: next_items_page = [item_type(self._options, self._session, raw_issue_json) for raw_issue_json in (resource[items_key] if items_key else resource)] except KeyError as e: # improving the error text so we know why it happened raise KeyError(str(e) + " : " + json.dumps(resource)) items.extend(next_items_page) page_start += page_size else: # if resource is an empty dictionary we assume no-results break return ResultList(items, start_at_from_response, max_results_from_response, total, is_last) else: # it seams that search_users can return a list() containing a single user! return ResultList([item_type(self._options, self._session, resource)], 0, 1, 1, True) # Information about this client def client_info(self): """Get the server this client is connected to.""" return self._options['server'] # Universal resource loading def find(self, resource_format, ids=None): """Find Resource object for any addressable resource on the server. This method is a universal resource locator for any RESTful resource in JIRA. The argument ``resource_format`` is a string of the form ``resource``, ``resource/{0}``, ``resource/{0}/sub``, ``resource/{0}/sub/{1}``, etc. The format placeholders will be populated from the ``ids`` argument if present. The existing authentication session will be used. The return value is an untyped Resource object, which will not support specialized :py:meth:`.Resource.update` or :py:meth:`.Resource.delete` behavior. Moreover, it will not know to return an issue Resource if the client uses the resource issue path. For this reason, it is intended to support resources that are not included in the standard Atlassian REST API. :param resource_format: the subpath to the resource string :param ids: values to substitute in the ``resource_format`` string :type ids: tuple or None """ resource = Resource(resource_format, self._options, self._session) resource.find(ids) return resource def async_do(self, size=10): """Execute all async jobs and wait for them to finish. By default it will run on 10 threads. :param size: number of threads to run on. """ if hasattr(self._session, '_async_jobs'): logging.info("Executing async %s jobs found in queue by using %s threads..." % ( len(self._session._async_jobs), size)) threaded_requests.map(self._session._async_jobs, size=size) # Application properties # non-resource def application_properties(self, key=None): """Return the mutable server application properties. :param key: the single property to return a value for """ params = {} if key is not None: params['key'] = key return self._get_json('application-properties', params=params) def set_application_property(self, key, value): """Set the application property. :param key: key of the property to set :param value: value to assign to the property """ url = self._options['server'] + \ '/rest/api/latest/application-properties/' + key payload = { 'id': key, 'value': value} return self._session.put( url, data=json.dumps(payload)) def applicationlinks(self, cached=True): """List of application links. :return: json """ # if cached, return the last result if cached and hasattr(self, '_applicationlinks'): return self._applicationlinks # url = self._options['server'] + '/rest/applinks/latest/applicationlink' url = self._options['server'] + \ '/rest/applinks/latest/listApplicationlinks' r = self._session.get(url) o = json_loads(r) if 'list' in o: self._applicationlinks = o['list'] else: self._applicationlinks = [] return self._applicationlinks # Attachments def attachment(self, id): """Get an attachment Resource from the server for the specified ID.""" return self._find_for_resource(Attachment, id) # non-resource def attachment_meta(self): """Get the attachment metadata.""" return self._get_json('attachment/meta') @translate_resource_args def add_attachment(self, issue, attachment, filename=None): """Attach an attachment to an issue and returns a Resource for it. The client will *not* attempt to open or validate the attachment; it expects a file-like object to be ready for its use. The user is still responsible for tidying up (e.g., closing the file, killing the socket, etc.) :param issue: the issue to attach the attachment to :param attachment: file-like object to attach to the issue, also works if it is a string with the filename. :param filename: optional name for the attached file. If omitted, the file object's ``name`` attribute is used. If you aquired the file-like object by any other method than ``open()``, make sure that a name is specified in one way or the other. :rtype: an Attachment Resource """ if isinstance(attachment, string_types): attachment = open(attachment, "rb") if hasattr(attachment, 'read') and hasattr(attachment, 'mode') and attachment.mode != 'rb': logging.warning( "%s was not opened in 'rb' mode, attaching file may fail." % attachment.name) url = self._get_url('issue/' + str(issue) + '/attachments') fname = filename if not fname: fname = os.path.basename(attachment.name) if 'MultipartEncoder' not in globals(): method = 'old' r = self._session.post( url, files={ 'file': (fname, attachment, 'application/octet-stream')}, headers=CaseInsensitiveDict({'content-type': None, 'X-Atlassian-Token': 'nocheck'})) else: method = 'MultipartEncoder' def file_stream(): return MultipartEncoder( fields={ 'file': (fname, attachment, 'application/octet-stream')}) m = file_stream() r = self._session.post( url, data=m, headers=CaseInsensitiveDict({'content-type': m.content_type, 'X-Atlassian-Token': 'nocheck'}), retry_data=file_stream) js = json_loads(r) if not js or not isinstance(js, collections.Iterable): raise JIRAError("Unable to parse JSON: %s" % js) attachment = Attachment(self._options, self._session, js[0]) if attachment.size == 0: raise JIRAError("Added empty attachment via %s method?!: r: %s\nattachment: %s" % (method, r, attachment)) return attachment def delete_attachment(self, id): """Delete attachment by id. :param id: ID of the attachment to delete """ url = self._get_url('attachment/' + str(id)) return self._session.delete(url) # Components def component(self, id): """Get a component Resource from the server. :param id: ID of the component to get """ return self._find_for_resource(Component, id) @translate_resource_args def create_component(self, name, project, description=None, leadUserName=None, assigneeType=None, isAssigneeTypeValid=False): """Create a component inside a project and return a Resource for it. :param name: name of the component :param project: key of the project to create the component in :param description: a description of the component :param leadUserName: the username of the user responsible for this component :param assigneeType: see the ComponentBean.AssigneeType class for valid values :param isAssigneeTypeValid: boolean specifying whether the assignee type is acceptable """ data = { 'name': name, 'project': project, 'isAssigneeTypeValid': isAssigneeTypeValid} if description is not None: data['description'] = description if leadUserName is not None: data['leadUserName'] = leadUserName if assigneeType is not None: data['assigneeType'] = assigneeType url = self._get_url('component') r = self._session.post( url, data=json.dumps(data)) component = Component(self._options, self._session, raw=json_loads(r)) return component def component_count_related_issues(self, id): """Get the count of related issues for a component. :type id: integer :param id: ID of the component to use """ return self._get_json('component/' + id + '/relatedIssueCounts')['issueCount'] # Custom field options def custom_field_option(self, id): """Get a custom field option Resource from the server. :param id: ID of the custom field to use """ return self._find_for_resource(CustomFieldOption, id) # Dashboards def dashboards(self, filter=None, startAt=0, maxResults=20): """Return a ResultList of Dashboard resources and a ``total`` count. :param filter: either "favourite" or "my", the type of dashboards to return :param startAt: index of the first dashboard to return :param maxResults: maximum number of dashboards to return. If maxResults evaluates as False, it will try to get all items in batches. :rtype: ResultList """ params = {} if filter is not None: params['filter'] = filter return self._fetch_pages(Dashboard, 'dashboards', 'dashboard', startAt, maxResults, params) def dashboard(self, id): """Get a dashboard Resource from the server. :param id: ID of the dashboard to get. """ return self._find_for_resource(Dashboard, id) # Fields # non-resource def fields(self): """Return a list of all issue fields.""" return self._get_json('field') # Filters def filter(self, id): """Get a filter Resource from the server. :param id: ID of the filter to get. """ return self._find_for_resource(Filter, id) def favourite_filters(self): """Get a list of filter Resources which are the favourites of the currently authenticated user.""" r_json = self._get_json('filter/favourite') filters = [Filter(self._options, self._session, raw_filter_json) for raw_filter_json in r_json] return filters def create_filter(self, name=None, description=None, jql=None, favourite=None): """Create a new filter and return a filter Resource for it. :param name: name of the new filter :param description: useful human readable description of the new filter :param jql: query string that defines the filter :param favourite: whether to add this filter to the current user's favorites """ data = {} if name is not None: data['name'] = name if description is not None: data['description'] = description if jql is not None: data['jql'] = jql if favourite is not None: data['favourite'] = favourite url = self._get_url('filter') r = self._session.post( url, data=json.dumps(data)) raw_filter_json = json_loads(r) return Filter(self._options, self._session, raw=raw_filter_json) def update_filter(self, filter_id, name=None, description=None, jql=None, favourite=None): """Update a filter and return a filter Resource for it. :param name: name of the new filter :param description: useful human readable description of the new filter :param jql: query string that defines the filter :param favourite: whether to add this filter to the current user's favorites """ filter = self.filter(filter_id) data = {} data['name'] = name or filter.name data['description'] = description or filter.description data['jql'] = jql or filter.jql data['favourite'] = favourite or filter.favourite url = self._get_url('filter/%s' % filter_id) r = self._session.put(url, headers={'content-type': 'application/json'}, data=json.dumps(data)) raw_filter_json = json.loads(r.text) return Filter(self._options, self._session, raw=raw_filter_json) # Groups # non-resource def groups(self, query=None, exclude=None, maxResults=9999): """Return a list of groups matching the specified criteria. :param query: filter groups by name with this string :param exclude: filter out groups by name with this string :param maxResults: maximum results to return. defaults to 9999 """ params = {} groups = [] if query is not None: params['query'] = query if exclude is not None: params['exclude'] = exclude if maxResults is not None: params['maxResults'] = maxResults for group in self._get_json('groups/picker', params=params)['groups']: groups.append(group['name']) return sorted(groups) def group_members(self, group): """Return a hash or users with their information. Requires JIRA 6.0 or will raise NotImplemented.""" if self._version < (6, 0, 0): raise NotImplementedError( "Group members is not implemented in JIRA before version 6.0, upgrade the instance, if possible.") params = {'groupname': group, 'expand': "users"} r = self._get_json('group', params=params) size = r['users']['size'] end_index = r['users']['end-index'] while end_index < size - 1: params = {'groupname': group, 'expand': "users[%s:%s]" % ( end_index + 1, end_index + 50)} r2 = self._get_json('group', params=params) for user in r2['users']['items']: r['users']['items'].append(user) end_index = r2['users']['end-index'] size = r['users']['size'] result = {} for user in r['users']['items']: result[user['name']] = {'fullname': user['displayName'], 'email': user.get('emailAddress', 'hidden'), 'active': user['active']} return result def add_group(self, groupname): """Create a new group in JIRA. :param groupname: The name of the group you wish to create. :return: Boolean - True if succesfull. """ url = self._options['server'] + '/rest/api/latest/group' # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 x = OrderedDict() x['name'] = groupname payload = json.dumps(x) self._session.post(url, data=payload) return True def remove_group(self, groupname): """Delete a group from the JIRA instance. :param groupname: The group to be deleted from the JIRA instance. :return: Boolean. Returns True on success. """ # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 url = self._options['server'] + '/rest/api/latest/group' x = {'groupname': groupname} self._session.delete(url, params=x) return True # Issues def issue(self, id, fields=None, expand=None): """Get an issue Resource from the server. :param id: ID or key of the issue to get :param fields: comma-separated string of issue fields to include in the results :param expand: extra information to fetch inside each resource """ # this allows us to pass Issue objects to issue() if isinstance(id, Issue): return id issue = Issue(self._options, self._session) params = {} if fields is not None: params['fields'] = fields if expand is not None: params['expand'] = expand issue.find(id, params=params) return issue def create_issue(self, fields=None, prefetch=True, **fieldargs): """Create a new issue and return an issue Resource for it. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments will be ignored. By default, the client will immediately reload the issue Resource created by this method in order to return a complete Issue object to the caller; this behavior can be controlled through the 'prefetch' argument. JIRA projects may contain many different issue types. Some issue screens have different requirements for fields in a new issue. This information is available through the 'createmeta' method. Further examples are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue :param fields: a dict containing field names and the values to use. If present, all other keyword arguments will be ignored :param prefetch: whether to reload the created issue Resource so that all of its data is present in the value returned from this method """ data = _field_worker(fields, **fieldargs) p = data['fields']['project'] if isinstance(p, string_types) or isinstance(p, integer_types): data['fields']['project'] = {'id': self.project(p).id} p = data['fields']['issuetype'] if isinstance(p, integer_types): data['fields']['issuetype'] = {'id': p} if isinstance(p, string_types) or isinstance(p, integer_types): data['fields']['issuetype'] = {'id': self.issue_type_by_name(p).id} url = self._get_url('issue') r = self._session.post(url, data=json.dumps(data)) raw_issue_json = json_loads(r) if 'key' not in raw_issue_json: raise JIRAError(r.status_code, response=r, url=url, text=json.dumps(data)) if prefetch: return self.issue(raw_issue_json['key']) else: return Issue(self._options, self._session, raw=raw_issue_json) def create_issues(self, field_list, prefetch=True): """Bulk create new issues and return an issue Resource for each successfully created issue. See `create_issue` documentation for field information. :param field_list: a list of dicts each containing field names and the values to use. Each dict is an individual issue to create and is subject to its minimum requirements. :param prefetch: whether to reload the created issue Resource for each created issue so that all of its data is present in the value returned from this method. """ data = {'issueUpdates': []} for field_dict in field_list: issue_data = _field_worker(field_dict) p = issue_data['fields']['project'] if isinstance(p, string_types) or isinstance(p, integer_types): issue_data['fields']['project'] = {'id': self.project(p).id} p = issue_data['fields']['issuetype'] if isinstance(p, integer_types): issue_data['fields']['issuetype'] = {'id': p} if isinstance(p, string_types) or isinstance(p, integer_types): issue_data['fields']['issuetype'] = {'id': self.issue_type_by_name(p).id} data['issueUpdates'].append(issue_data) url = self._get_url('issue/bulk') r = self._session.post(url, data=json.dumps(data)) raw_issue_json = json_loads(r) issue_list = [] errors = {} for error in raw_issue_json['errors']: errors[error['failedElementNumber']] = error['elementErrors']['errors'] for index, fields in enumerate(field_list): if index in errors: issue_list.append({'status': 'Error', 'error': errors[index], 'issue': None, 'input_fields': fields}) else: issue = raw_issue_json['issues'].pop(0) if prefetch: issue = self.issue(issue['key']) else: issue = Issue(self._options, self._session, raw=issue) issue_list.append({'status': 'Success', 'issue': issue, 'error': None, 'input_fields': fields}) return issue_list def createmeta(self, projectKeys=None, projectIds=[], issuetypeIds=None, issuetypeNames=None, expand=None): """Get the metadata required to create issues, optionally filtered by projects and issue types. :param projectKeys: keys of the projects to filter the results with. Can be a single value or a comma-delimited string. May be combined with projectIds. :param projectIds: IDs of the projects to filter the results with. Can be a single value or a comma-delimited string. May be combined with projectKeys. :param issuetypeIds: IDs of the issue types to filter the results with. Can be a single value or a comma-delimited string. May be combined with issuetypeNames. :param issuetypeNames: Names of the issue types to filter the results with. Can be a single value or a comma-delimited string. May be combined with issuetypeIds. :param expand: extra information to fetch inside each resource. """ params = {} if projectKeys is not None: params['projectKeys'] = projectKeys if projectIds is not None: if isinstance(projectIds, string_types): projectIds = projectIds.split(',') params['projectIds'] = projectIds if issuetypeIds is not None: params['issuetypeIds'] = issuetypeIds if issuetypeNames is not None: params['issuetypeNames'] = issuetypeNames if expand is not None: params['expand'] = expand return self._get_json('issue/createmeta', params) # non-resource @translate_resource_args def assign_issue(self, issue, assignee): """Assign an issue to a user. None will set it to unassigned. -1 will set it to Automatic. :param issue: the issue to assign :param assignee: the user to assign the issue to """ url = self._options['server'] + \ '/rest/api/latest/issue/' + str(issue) + '/assignee' payload = {'name': assignee} r = self._session.put( url, data=json.dumps(payload)) raise_on_error(r) return True @translate_resource_args def comments(self, issue): """Get a list of comment Resources. :param issue: the issue to get comments from """ r_json = self._get_json('issue/' + str(issue) + '/comment') comments = [Comment(self._options, self._session, raw_comment_json) for raw_comment_json in r_json['comments']] return comments @translate_resource_args def comment(self, issue, comment): """Get a comment Resource from the server for the specified ID. :param issue: ID or key of the issue to get the comment from :param comment: ID of the comment to get """ return self._find_for_resource(Comment, (issue, comment)) @translate_resource_args def add_comment(self, issue, body, visibility=None): """Add a comment from the current authenticated user on the specified issue and return a Resource for it. The issue identifier and comment body are required. :param issue: ID or key of the issue to add the comment to :param body: Text of the comment to add :param visibility: a dict containing two entries: "type" and "value". "type" is 'role' (or 'group' if the JIRA server has configured comment visibility for groups) and 'value' is the name of the role (or group) to which viewing of this comment will be restricted. """ data = { 'body': body} if visibility is not None: data['visibility'] = visibility url = self._get_url('issue/' + str(issue) + '/comment') r = self._session.post( url, data=json.dumps(data)) comment = Comment(self._options, self._session, raw=json_loads(r)) return comment # non-resource @translate_resource_args def editmeta(self, issue): """Get the edit metadata for an issue. :param issue: the issue to get metadata for """ return self._get_json('issue/' + str(issue) + '/editmeta') @translate_resource_args def remote_links(self, issue): """Get a list of remote link Resources from an issue. :param issue: the issue to get remote links from """ r_json = self._get_json('issue/' + str(issue) + '/remotelink') remote_links = [RemoteLink( self._options, self._session, raw_remotelink_json) for raw_remotelink_json in r_json] return remote_links @translate_resource_args def remote_link(self, issue, id): """Get a remote link Resource from the server. :param issue: the issue holding the remote link :param id: ID of the remote link """ return self._find_for_resource(RemoteLink, (issue, id)) # removed the @translate_resource_args because it prevents us from finding # information for building a proper link def add_remote_link(self, issue, destination, globalId=None, application=None, relationship=None): """Add a remote link from an issue to an external application and returns a remote link Resource for it. ``object`` should be a dict containing at least ``url`` to the linked external URL and ``title`` to display for the link inside JIRA. For definitions of the allowable fields for ``object`` and the keyword arguments ``globalId``, ``application`` and ``relationship``, see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. :param issue: the issue to add the remote link to :param destination: the link details to add (see the above link for details) :param globalId: unique ID for the link (see the above link for details) :param application: application information for the link (see the above link for details) :param relationship: relationship description for the link (see the above link for details) """ try: applicationlinks = self.applicationlinks() except JIRAError as e: applicationlinks = [] # In many (if not most) configurations, non-admin users are # not allowed to list applicationlinks; if we aren't allowed, # let's let people try to add remote links anyway, we just # won't be able to be quite as helpful. warnings.warn( "Unable to gather applicationlinks; you will not be able " "to add links to remote issues: (%s) %s" % ( e.status_code, e.text), Warning) data = {} if isinstance(destination, Issue): data['object'] = { 'title': str(destination), 'url': destination.permalink()} for x in applicationlinks: if x['application']['displayUrl'] == destination._options['server']: data['globalId'] = "appId=%s&issueId=%s" % ( x['application']['id'], destination.raw['id']) data['application'] = { 'name': x['application']['name'], 'type': "com.atlassian.jira"} break if 'globalId' not in data: raise NotImplementedError( "Unable to identify the issue to link to.") else: if globalId is not None: data['globalId'] = globalId if application is not None: data['application'] = application data['object'] = destination if relationship is not None: data['relationship'] = relationship # check if the link comes from one of the configured application links for x in applicationlinks: if x['application']['displayUrl'] == self._options['server']: data['globalId'] = "appId=%s&issueId=%s" % ( x['application']['id'], destination.raw['id']) data['application'] = { 'name': x['application']['name'], 'type': "com.atlassian.jira"} break url = self._get_url('issue/' + str(issue) + '/remotelink') r = self._session.post( url, data=json.dumps(data)) remote_link = RemoteLink( self._options, self._session, raw=json_loads(r)) return remote_link def add_simple_link(self, issue, object): """Add a simple remote link from an issue to web resource. This avoids the admin access problems from add_remote_link by just using a simple object and presuming all fields are correct and not requiring more complex ``application`` data. ``object`` should be a dict containing at least ``url`` to the linked external URL and ``title`` to display for the link inside JIRA. For definitions of the allowable fields for ``object`` , see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. :param issue: the issue to add the remote link to :param object: the dictionary used to create remotelink data """ data = {"object": object} url = self._get_url('issue/' + str(issue) + '/remotelink') r = self._session.post( url, data=json.dumps(data)) simple_link = RemoteLink( self._options, self._session, raw=json_loads(r)) return simple_link # non-resource @translate_resource_args def transitions(self, issue, id=None, expand=None): """Get a list of the transitions available on the specified issue to the current user. :param issue: ID or key of the issue to get the transitions from :param id: if present, get only the transition matching this ID :param expand: extra information to fetch inside each transition """ params = {} if id is not None: params['transitionId'] = id if expand is not None: params['expand'] = expand return self._get_json('issue/' + str(issue) + '/transitions', params=params)['transitions'] def find_transitionid_by_name(self, issue, transition_name): """Get a transitionid available on the specified issue to the current user. Look at https://developer.atlassian.com/static/rest/jira/6.1.html#d2e1074 for json reference :param issue: ID or key of the issue to get the transitions from :param trans_name: iname of transition we are looking for """ transitions_json = self.transitions(issue) id = None for transition in transitions_json: if transition["name"].lower() == transition_name.lower(): id = transition["id"] break return id @translate_resource_args def transition_issue(self, issue, transition, fields=None, comment=None, **fieldargs): """Perform a transition on an issue. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments will be ignored. Field values will be set on the issue as part of the transition process. :param issue: ID or key of the issue to perform the transition on :param transition: ID or name of the transition to perform :param comment: *Optional* String to add as comment to the issue when performing the transition. :param fields: a dict containing field names and the values to use. If present, all other keyword arguments will be ignored """ transitionId = None try: transitionId = int(transition) except Exception: # cannot cast to int, so try to find transitionId by name transitionId = self.find_transitionid_by_name(issue, transition) if transitionId is None: raise JIRAError("Invalid transition name. %s" % transition) data = { 'transition': { 'id': transitionId}} if comment: data['update'] = {'comment': [{'add': {'body': comment}}]} if fields is not None: data['fields'] = fields else: fields_dict = {} for field in fieldargs: fields_dict[field] = fieldargs[field] data['fields'] = fields_dict url = self._get_url('issue/' + str(issue) + '/transitions') r = self._session.post( url, data=json.dumps(data)) try: r_json = json_loads(r) except ValueError as e: logging.error("%s\n%s" % (e, r.text)) raise e return r_json @translate_resource_args def votes(self, issue): """Get a votes Resource from the server. :param issue: ID or key of the issue to get the votes for """ return self._find_for_resource(Votes, issue) @translate_resource_args def add_vote(self, issue): """Register a vote for the current authenticated user on an issue. :param issue: ID or key of the issue to vote on """ url = self._get_url('issue/' + str(issue) + '/votes') return self._session.post(url) @translate_resource_args def remove_vote(self, issue): """Remove the current authenticated user's vote from an issue. :param issue: ID or key of the issue to unvote on """ url = self._get_url('issue/' + str(issue) + '/votes') self._session.delete(url) @translate_resource_args def watchers(self, issue): """Get a watchers Resource from the server for an issue. :param issue: ID or key of the issue to get the watchers for """ return self._find_for_resource(Watchers, issue) @translate_resource_args def add_watcher(self, issue, watcher): """Add a user to an issue's watchers list. :param issue: ID or key of the issue affected :param watcher: username of the user to add to the watchers list """ url = self._get_url('issue/' + str(issue) + '/watchers') self._session.post( url, data=json.dumps(watcher)) @translate_resource_args def remove_watcher(self, issue, watcher): """Remove a user from an issue's watch list. :param issue: ID or key of the issue affected :param watcher: username of the user to remove from the watchers list """ url = self._get_url('issue/' + str(issue) + '/watchers') params = {'username': watcher} result = self._session.delete(url, params=params) return result @translate_resource_args def worklogs(self, issue): """Get a list of worklog Resources from the server for an issue. :param issue: ID or key of the issue to get worklogs from """ r_json = self._get_json('issue/' + str(issue) + '/worklog') worklogs = [Worklog(self._options, self._session, raw_worklog_json) for raw_worklog_json in r_json['worklogs']] return worklogs @translate_resource_args def worklog(self, issue, id): """Get a specific worklog Resource from the server. :param issue: ID or key of the issue to get the worklog from :param id: ID of the worklog to get """ return self._find_for_resource(Worklog, (issue, id)) @translate_resource_args def add_worklog(self, issue, timeSpent=None, timeSpentSeconds=None, adjustEstimate=None, newEstimate=None, reduceBy=None, comment=None, started=None, user=None): """Add a new worklog entry on an issue and return a Resource for it. :param issue: the issue to add the worklog to :param timeSpent: a worklog entry with this amount of time spent, e.g. "2d" :param adjustEstimate: (optional) allows the user to provide specific instructions to update the remaining time estimate of the issue. The value can either be ``new``, ``leave``, ``manual`` or ``auto`` (default). :param newEstimate: the new value for the remaining estimate field. e.g. "2d" :param reduceBy: the amount to reduce the remaining estimate by e.g. "2d" :param started: Moment when the work is logged, if not specified will default to now :param comment: optional worklog comment """ params = {} if adjustEstimate is not None: params['adjustEstimate'] = adjustEstimate if newEstimate is not None: params['newEstimate'] = newEstimate if reduceBy is not None: params['reduceBy'] = reduceBy data = {} if timeSpent is not None: data['timeSpent'] = timeSpent if timeSpentSeconds is not None: data['timeSpentSeconds'] = timeSpentSeconds if comment is not None: data['comment'] = comment elif user: # we log user inside comment as it doesn't always work data['comment'] = user if started is not None: # based on REST Browser it needs: "2014-06-03T08:21:01.273+0000" data['started'] = started.strftime("%Y-%m-%dT%H:%M:%S.000%z") if user is not None: data['author'] = {"name": user, 'self': self.JIRA_BASE_URL + '/rest/api/latest/user?username=' + user, 'displayName': user, 'active': False } data['updateAuthor'] = data['author'] # report bug to Atlassian: author and updateAuthor parameters are # ignored. url = self._get_url('issue/{0}/worklog'.format(issue)) r = self._session.post(url, params=params, data=json.dumps(data)) return Worklog(self._options, self._session, json_loads(r)) # Issue links @translate_resource_args def create_issue_link(self, type, inwardIssue, outwardIssue, comment=None): """Create a link between two issues. :param type: the type of link to create :param inwardIssue: the issue to link from :param outwardIssue: the issue to link to :param comment: a comment to add to the issues with the link. Should be a dict containing ``body`` and ``visibility`` fields: ``body`` being the text of the comment and ``visibility`` being a dict containing two entries: ``type`` and ``value``. ``type`` is ``role`` (or ``group`` if the JIRA server has configured comment visibility for groups) and ``value`` is the name of the role (or group) to which viewing of this comment will be restricted. """ # let's see if we have the right issue link 'type' and fix it if needed if not hasattr(self, '_cached_issuetypes'): self._cached_issue_link_types = self.issue_link_types() if type not in self._cached_issue_link_types: for lt in self._cached_issue_link_types: if lt.outward == type: # we are smart to figure it out what he ment type = lt.name break elif lt.inward == type: # so that's the reverse, so we fix the request type = lt.name inwardIssue, outwardIssue = outwardIssue, inwardIssue break data = { 'type': { 'name': type}, 'inwardIssue': { 'key': inwardIssue}, 'outwardIssue': { 'key': outwardIssue}, 'comment': comment} url = self._get_url('issueLink') return self._session.post( url, data=json.dumps(data)) def delete_issue_link(self, id): """Delete a link between two issues. :param id: ID of the issue link to delete """ url = self._get_url('issueLink') + "/" + id return self._session.delete(url) def issue_link(self, id): """Get an issue link Resource from the server. :param id: ID of the issue link to get """ return self._find_for_resource(IssueLink, id) # Issue link types def issue_link_types(self): """Get a list of issue link type Resources from the server.""" r_json = self._get_json('issueLinkType') link_types = [IssueLinkType(self._options, self._session, raw_link_json) for raw_link_json in r_json['issueLinkTypes']] return link_types def issue_link_type(self, id): """Get an issue link type Resource from the server. :param id: ID of the issue link type to get """ return self._find_for_resource(IssueLinkType, id) # Issue types def issue_types(self): """Get a list of issue type Resources from the server.""" r_json = self._get_json('issuetype') issue_types = [IssueType( self._options, self._session, raw_type_json) for raw_type_json in r_json] return issue_types def issue_type(self, id): """Get an issue type Resource from the server. :param id: ID of the issue type to get """ return self._find_for_resource(IssueType, id) def issue_type_by_name(self, name): issue_types = self.issue_types() try: issue_type = [it for it in issue_types if it.name == name][0] except IndexError: raise KeyError("Issue type '%s' is unknown." % name) return issue_type # User permissions # non-resource def my_permissions(self, projectKey=None, projectId=None, issueKey=None, issueId=None): """Get a dict of all available permissions on the server. :param projectKey: limit returned permissions to the specified project :param projectId: limit returned permissions to the specified project :param issueKey: limit returned permissions to the specified issue :param issueId: limit returned permissions to the specified issue """ params = {} if projectKey is not None: params['projectKey'] = projectKey if projectId is not None: params['projectId'] = projectId if issueKey is not None: params['issueKey'] = issueKey if issueId is not None: params['issueId'] = issueId return self._get_json('mypermissions', params=params) # Priorities def priorities(self): """Get a list of priority Resources from the server.""" r_json = self._get_json('priority') priorities = [Priority( self._options, self._session, raw_priority_json) for raw_priority_json in r_json] return priorities def priority(self, id): """Get a priority Resource from the server. :param id: ID of the priority to get """ return self._find_for_resource(Priority, id) # Projects def projects(self): """Get a list of project Resources from the server visible to the current authenticated user.""" r_json = self._get_json('project') projects = [Project( self._options, self._session, raw_project_json) for raw_project_json in r_json] return projects def project(self, id): """Get a project Resource from the server. :param id: ID or key of the project to get """ return self._find_for_resource(Project, id) # non-resource @translate_resource_args def project_avatars(self, project): """Get a dict of all avatars for a project visible to the current authenticated user. :param project: ID or key of the project to get avatars for """ return self._get_json('project/' + project + '/avatars') @translate_resource_args def create_temp_project_avatar(self, project, filename, size, avatar_img, contentType=None, auto_confirm=False): """Register an image file as a project avatar. The avatar created is temporary and must be confirmed before it can be used. Avatar images are specified by a filename, size, and file object. By default, the client will attempt to autodetect the picture's content type: this mechanism relies on libmagic and will not work out of the box on Windows systems (see http://filemagic.readthedocs.org/en/latest/guide.html for details on how to install support). The ``contentType`` argument can be used to explicitly set the value (note that JIRA will reject any type other than the well-known ones for images, e.g. ``image/jpg``, ``image/png``, etc.) This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This dict should be saved and passed to :py:meth:`confirm_project_avatar` to finish the avatar creation process. If you want to cut out the middleman and confirm the avatar with JIRA's default cropping, pass the 'auto_confirm' argument with a truthy value and :py:meth:`confirm_project_avatar` will be called for you before this method returns. :param project: ID or key of the project to create the avatar in :param filename: name of the avatar file :param size: size of the avatar file :param avatar_img: file-like object holding the avatar :param contentType: explicit specification for the avatar image's content-type :param boolean auto_confirm: whether to automatically confirm the temporary avatar by calling :py:meth:`confirm_project_avatar` with the return value of this method. """ size_from_file = os.path.getsize(filename) if size != size_from_file: size = size_from_file params = { 'filename': filename, 'size': size} headers = {'X-Atlassian-Token': 'no-check'} if contentType is not None: headers['content-type'] = contentType else: # try to detect content-type, this may return None headers['content-type'] = self._get_mime_type(avatar_img) url = self._get_url('project/' + project + '/avatar/temporary') r = self._session.post( url, params=params, headers=headers, data=avatar_img) cropping_properties = json_loads(r) if auto_confirm: return self.confirm_project_avatar(project, cropping_properties) else: return cropping_properties @translate_resource_args def confirm_project_avatar(self, project, cropping_properties): """Confirm the temporary avatar image previously uploaded with the specified cropping. After a successful registry with :py:meth:`create_temp_project_avatar`, use this method to confirm the avatar for use. The final avatar can be a subarea of the uploaded image, which is customized with the ``cropping_properties``: the return value of :py:meth:`create_temp_project_avatar` should be used for this argument. :param project: ID or key of the project to confirm the avatar in :param cropping_properties: a dict of cropping properties from :py:meth:`create_temp_project_avatar` """ data = cropping_properties url = self._get_url('project/' + project + '/avatar') r = self._session.post( url, data=json.dumps(data)) return json_loads(r) @translate_resource_args def set_project_avatar(self, project, avatar): """Set a project's avatar. :param project: ID or key of the project to set the avatar on :param avatar: ID of the avatar to set """ self._set_avatar( None, self._get_url('project/' + project + '/avatar'), avatar) @translate_resource_args def delete_project_avatar(self, project, avatar): """Delete a project's avatar. :param project: ID or key of the project to delete the avatar from :param avatar: ID of the avater to delete """ url = self._get_url('project/' + project + '/avatar/' + avatar) return self._session.delete(url) @translate_resource_args def project_components(self, project): """Get a list of component Resources present on a project. :param project: ID or key of the project to get components from """ r_json = self._get_json('project/' + project + '/components') components = [Component( self._options, self._session, raw_comp_json) for raw_comp_json in r_json] return components @translate_resource_args def project_versions(self, project): """Get a list of version Resources present on a project. :param project: ID or key of the project to get versions from """ r_json = self._get_json('project/' + project + '/versions') versions = [ Version(self._options, self._session, raw_ver_json) for raw_ver_json in r_json] return versions # non-resource @translate_resource_args def project_roles(self, project): """Get a dict of role names to resource locations for a project. :param project: ID or key of the project to get roles from """ roles_dict = self._get_json('project/' + project + '/role') return roles_dict # TODO(ssbarnea): return a list of Roles() @translate_resource_args def project_role(self, project, id): """Get a role Resource. :param project: ID or key of the project to get the role from :param id: ID of the role to get """ if isinstance(id, Number): id = "%s" % id return self._find_for_resource(Role, (project, id)) # Resolutions def resolutions(self): """Get a list of resolution Resources from the server.""" r_json = self._get_json('resolution') resolutions = [Resolution( self._options, self._session, raw_res_json) for raw_res_json in r_json] return resolutions def resolution(self, id): """Get a resolution Resource from the server. :param id: ID of the resolution to get """ return self._find_for_resource(Resolution, id) # Search def search_issues(self, jql_str, startAt=0, maxResults=50, validate_query=True, fields=None, expand=None, json_result=None): """Get a ResultList of issue Resources matching a JQL search string. :param jql_str: the JQL search string to use :param startAt: index of the first issue to return :param maxResults: maximum number of issues to return. Total number of results is available in the ``total`` attribute of the returned ResultList. If maxResults evaluates as False, it will try to get all issues in batches. :param fields: comma-separated string of issue fields to include in the results :param expand: extra information to fetch inside each resource :param json_result: JSON response will be returned when this parameter is set to True. Otherwise, ResultList will be returned. """ if fields is None: fields = [] if isinstance(fields, string_types): fields = fields.split(",") # this will translate JQL field names to REST API Name # most people do know the JQL names so this will help them use the API easier untranslate = {} # use to add friendly aliases when we get the results back if self._fields: for i, field in enumerate(fields): if field in self._fields: untranslate[self._fields[field]] = fields[i] fields[i] = self._fields[field] search_params = { "jql": jql_str, "startAt": startAt, "validateQuery": validate_query, "fields": fields, "expand": expand} if json_result: search_params["maxResults"] = maxResults if not maxResults: warnings.warn('All issues cannot be fetched at once, when json_result parameter is set', Warning) return self._get_json('search', params=search_params) issues = self._fetch_pages(Issue, 'issues', 'search', startAt, maxResults, search_params) if untranslate: for i in issues: for k, v in iteritems(untranslate): if k in i.raw.get('fields', {}): i.raw['fields'][v] = i.raw['fields'][k] return issues # Security levels def security_level(self, id): """Get a security level Resource. :param id: ID of the security level to get """ return self._find_for_resource(SecurityLevel, id) # Server info # non-resource def server_info(self): """Get a dict of server information for this JIRA instance.""" retry = 0 j = self._get_json('serverInfo') while not j and retry < 3: logging.warning("Bug https://jira.atlassian.com/browse/JRA-59676 trying again...") retry += 1 j = self._get_json('serverInfo') return j def myself(self): """Get a dict of server information for this JIRA instance.""" return self._get_json('myself') # Status def statuses(self): """Get a list of status Resources from the server.""" r_json = self._get_json('status') statuses = [Status(self._options, self._session, raw_stat_json) for raw_stat_json in r_json] return statuses def status(self, id): """Get a status Resource from the server. :param id: ID of the status resource to get """ return self._find_for_resource(Status, id) # Users def user(self, id, expand=None): """Get a user Resource from the server. :param id: ID of the user to get :param expand: extra information to fetch inside each resource """ user = User(self._options, self._session) params = {} if expand is not None: params['expand'] = expand user.find(id, params=params) return user def search_assignable_users_for_projects(self, username, projectKeys, startAt=0, maxResults=50): """Get a list of user Resources that match the search string and can be assigned issues for projects. :param username: a string to match usernames against :param projectKeys: comma-separated list of project keys to check for issue assignment permissions :param startAt: index of the first user to return :param maxResults: maximum number of users to return. If maxResults evaluates as False, it will try to get all users in batches. """ params = { 'username': username, 'projectKeys': projectKeys} return self._fetch_pages(User, None, 'user/assignable/multiProjectSearch', startAt, maxResults, params) def search_assignable_users_for_issues(self, username, project=None, issueKey=None, expand=None, startAt=0, maxResults=50): """Get a list of user Resources that match the search string for assigning or creating issues. This method is intended to find users that are eligible to create issues in a project or be assigned to an existing issue. When searching for eligible creators, specify a project. When searching for eligible assignees, specify an issue key. :param username: a string to match usernames against :param project: filter returned users by permission in this project (expected if a result will be used to create an issue) :param issueKey: filter returned users by this issue (expected if a result will be used to edit this issue) :param expand: extra information to fetch inside each resource :param startAt: index of the first user to return :param maxResults: maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. """ params = { 'username': username} if project is not None: params['project'] = project if issueKey is not None: params['issueKey'] = issueKey if expand is not None: params['expand'] = expand return self._fetch_pages(User, None, 'user/assignable/search', startAt, maxResults, params) # non-resource def user_avatars(self, username): """Get a dict of avatars for the specified user. :param username: the username to get avatars for """ return self._get_json('user/avatars', params={'username': username}) def create_temp_user_avatar(self, user, filename, size, avatar_img, contentType=None, auto_confirm=False): """Register an image file as a user avatar. The avatar created is temporary and must be confirmed before it can be used. Avatar images are specified by a filename, size, and file object. By default, the client will attempt to autodetect the picture's content type: this mechanism relies on ``libmagic`` and will not work out of the box on Windows systems (see http://filemagic.readthedocs.org/en/latest/guide.html for details on how to install support). The ``contentType`` argument can be used to explicitly set the value (note that JIRA will reject any type other than the well-known ones for images, e.g. ``image/jpg``, ``image/png``, etc.) This method returns a dict of properties that can be used to crop a subarea of a larger image for use. This dict should be saved and passed to :py:meth:`confirm_user_avatar` to finish the avatar creation process. If you want to cut out the middleman and confirm the avatar with JIRA's default cropping, pass the ``auto_confirm`` argument with a truthy value and :py:meth:`confirm_user_avatar` will be called for you before this method returns. :param user: user to register the avatar for :param filename: name of the avatar file :param size: size of the avatar file :param avatar_img: file-like object containing the avatar :param contentType: explicit specification for the avatar image's content-type :param auto_confirm: whether to automatically confirm the temporary avatar by calling :py:meth:`confirm_user_avatar` with the return value of this method. """ size_from_file = os.path.getsize(filename) if size != size_from_file: size = size_from_file # remove path from filename filename = os.path.split(filename)[1] params = { 'username': user, 'filename': filename, 'size': size} headers = {'X-Atlassian-Token': 'no-check'} if contentType is not None: headers['content-type'] = contentType else: # try to detect content-type, this may return None headers['content-type'] = self._get_mime_type(avatar_img) url = self._get_url('user/avatar/temporary') r = self._session.post( url, params=params, headers=headers, data=avatar_img) cropping_properties = json_loads(r) if auto_confirm: return self.confirm_user_avatar(user, cropping_properties) else: return cropping_properties def confirm_user_avatar(self, user, cropping_properties): """Confirm the temporary avatar image previously uploaded with the specified cropping. After a successful registry with :py:meth:`create_temp_user_avatar`, use this method to confirm the avatar for use. The final avatar can be a subarea of the uploaded image, which is customized with the ``cropping_properties``: the return value of :py:meth:`create_temp_user_avatar` should be used for this argument. :param user: the user to confirm the avatar for :param cropping_properties: a dict of cropping properties from :py:meth:`create_temp_user_avatar` """ data = cropping_properties url = self._get_url('user/avatar') r = self._session.post(url, params={'username': user}, data=json.dumps(data)) return json_loads(r) def set_user_avatar(self, username, avatar): """Set a user's avatar. :param username: the user to set the avatar for :param avatar: ID of the avatar to set """ self._set_avatar( {'username': username}, self._get_url('user/avatar'), avatar) def delete_user_avatar(self, username, avatar): """Delete a user's avatar. :param username: the user to delete the avatar from :param avatar: ID of the avatar to remove """ params = {'username': username} url = self._get_url('user/avatar/' + avatar) return self._session.delete(url, params=params) def search_users(self, user, startAt=0, maxResults=50, includeActive=True, includeInactive=False): """Get a list of user Resources that match the specified search string. :param user: a string to match usernames, name or email against. :param startAt: index of the first user to return. :param maxResults: maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. :param includeActive: If true, then active users are included in the results. :param includeInactive: If true, then inactive users are included in the results. """ params = { 'username': user, 'includeActive': includeActive, 'includeInactive': includeInactive} return self._fetch_pages(User, None, 'user/search', startAt, maxResults, params) def search_allowed_users_for_issue(self, user, issueKey=None, projectKey=None, startAt=0, maxResults=50): """Get a list of user Resources that match a username string and have browse permission for the issue or project. :param user: a string to match usernames against. :param issueKey: find users with browse permission for this issue. :param projectKey: find users with browse permission for this project. :param startAt: index of the first user to return. :param maxResults: maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. """ params = { 'username': user} if issueKey is not None: params['issueKey'] = issueKey if projectKey is not None: params['projectKey'] = projectKey return self._fetch_pages(User, None, 'user/viewissue/search', startAt, maxResults, params) # Versions @translate_resource_args def create_version(self, name, project, description=None, releaseDate=None, startDate=None, archived=False, released=False): """Create a version in a project and return a Resource for it. :param name: name of the version to create :param project: key of the project to create the version in :param description: a description of the version :param releaseDate: the release date assigned to the version :param startDate: The start date for the version """ data = { 'name': name, 'project': project, 'archived': archived, 'released': released} if description is not None: data['description'] = description if releaseDate is not None: data['releaseDate'] = releaseDate if startDate is not None: data['startDate'] = startDate url = self._get_url('version') r = self._session.post( url, data=json.dumps(data)) time.sleep(1) version = Version(self._options, self._session, raw=json_loads(r)) return version def move_version(self, id, after=None, position=None): """Move a version within a project's ordered version list and return a new version Resource for it. One, but not both, of ``after`` and ``position`` must be specified. :param id: ID of the version to move :param after: the self attribute of a version to place the specified version after (that is, higher in the list) :param position: the absolute position to move this version to: must be one of ``First``, ``Last``, ``Earlier``, or ``Later`` """ data = {} if after is not None: data['after'] = after elif position is not None: data['position'] = position url = self._get_url('version/' + id + '/move') r = self._session.post( url, data=json.dumps(data)) version = Version(self._options, self._session, raw=json_loads(r)) return version def version(self, id, expand=None): """Get a version Resource. :param id: ID of the version to get :param expand: extra information to fetch inside each resource """ version = Version(self._options, self._session) params = {} if expand is not None: params['expand'] = expand version.find(id, params=params) return version def version_count_related_issues(self, id): """Get a dict of the counts of issues fixed and affected by a version. :param id: the version to count issues for """ r_json = self._get_json('version/' + id + '/relatedIssueCounts') del r_json['self'] # this isn't really an addressable resource return r_json def version_count_unresolved_issues(self, id): """Get the number of unresolved issues for a version. :param id: ID of the version to count issues for """ return self._get_json('version/' + id + '/unresolvedIssueCount')['issuesUnresolvedCount'] # Session authentication def session(self): """Get a dict of the current authenticated user's session information.""" url = '{server}/rest/auth/1/session'.format(**self._options) if isinstance(self._session.auth, tuple): authentication_data = { 'username': self._session.auth[0], 'password': self._session.auth[1]} r = self._session.post(url, data=json.dumps(authentication_data)) else: r = self._session.get(url) user = User(self._options, self._session, json_loads(r)) return user def kill_session(self): """Destroy the session of the current authenticated user.""" url = self._options['server'] + '/rest/auth/latest/session' return self._session.delete(url) # Websudo def kill_websudo(self): """Destroy the user's current WebSudo session. Works only for non-cloud deployments, for others does nothing. """ if self.deploymentType != 'Cloud': url = self._options['server'] + '/rest/auth/1/websudo' return self._session.delete(url) # Utilities def _create_http_basic_session(self, username, password, timeout=None): verify = self._options['verify'] self._session = ResilientSession(timeout=timeout) self._session.verify = verify self._session.auth = (username, password) self._session.cert = self._options['client_cert'] def _create_oauth_session(self, oauth, timeout): verify = self._options['verify'] from oauthlib.oauth1 import SIGNATURE_RSA from requests_oauthlib import OAuth1 oauth = OAuth1( oauth['consumer_key'], rsa_key=oauth['key_cert'], signature_method=SIGNATURE_RSA, resource_owner_key=oauth['access_token'], resource_owner_secret=oauth['access_token_secret']) self._session = ResilientSession(timeout) self._session.verify = verify self._session.auth = oauth def _create_kerberos_session(self, timeout): verify = self._options['verify'] from requests_kerberos import HTTPKerberosAuth from requests_kerberos import OPTIONAL self._session = ResilientSession(timeout=timeout) self._session.verify = verify self._session.auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) @staticmethod def _timestamp(dt=None): t = datetime.datetime.utcnow() if dt is not None: t += dt return calendar.timegm(t.timetuple()) def _create_jwt_session(self, jwt, timeout): try: jwt_auth = JWTAuth(jwt['secret'], alg='HS256') except NameError as e: logging.error("JWT authentication requires requests_jwt") raise e jwt_auth.add_field("iat", lambda req: JIRA._timestamp()) jwt_auth.add_field("exp", lambda req: JIRA._timestamp(datetime.timedelta(minutes=3))) jwt_auth.add_field("qsh", QshGenerator(self._options['context_path'])) for f in jwt['payload'].items(): jwt_auth.add_field(f[0], f[1]) self._session = ResilientSession(timeout=timeout) self._session.verify = self._options['verify'] self._session.auth = jwt_auth def _set_avatar(self, params, url, avatar): data = { 'id': avatar} return self._session.put(url, params=params, data=json.dumps(data)) def _get_url(self, path, base=JIRA_BASE_URL): options = self._options.copy() options.update({'path': path}) return base.format(**options) def _get_json(self, path, params=None, base=JIRA_BASE_URL): url = self._get_url(path, base) r = self._session.get(url, params=params) try: r_json = json_loads(r) except ValueError as e: logging.error("%s\n%s" % (e, r.text)) raise e return r_json def _find_for_resource(self, resource_cls, ids, expand=None): resource = resource_cls(self._options, self._session) params = {} if expand is not None: params['expand'] = expand resource.find(id=ids, params=params) if not resource: raise JIRAError("Unable to find resource %s(%s)", resource_cls, ids) return resource def _try_magic(self): try: import magic import weakref except ImportError: self._magic = None else: try: _magic = magic.Magic(flags=magic.MAGIC_MIME_TYPE) def cleanup(x): _magic.close() self._magic_weakref = weakref.ref(self, cleanup) self._magic = _magic except TypeError: self._magic = None except AttributeError: self._magic = None def _get_mime_type(self, buff): if self._magic is not None: return self._magic.id_buffer(buff) else: try: return mimetypes.guess_type("f." + imghdr.what(0, buff))[0] except (IOError, TypeError): logging.warning("Couldn't detect content type of avatar image" ". Specify the 'contentType' parameter explicitly.") return None def email_user(self, user, body, title="JIRA Notification"): """(Obsolete) Send an email to an user via CannedScriptRunner.""" url = self._options['server'] + \ '/secure/admin/groovy/CannedScriptRunner.jspa' payload = { 'cannedScript': 'com.onresolve.jira.groovy.canned.workflow.postfunctions.SendCustomEmail', 'cannedScriptArgs_FIELD_CONDITION': '', 'cannedScriptArgs_FIELD_EMAIL_TEMPLATE': body, 'cannedScriptArgs_FIELD_EMAIL_SUBJECT_TEMPLATE': title, 'cannedScriptArgs_FIELD_EMAIL_FORMAT': 'TEXT', 'cannedScriptArgs_FIELD_TO_ADDRESSES': self.user(user).emailAddress, 'cannedScriptArgs_FIELD_TO_USER_FIELDS': '', 'cannedScriptArgs_FIELD_INCLUDE_ATTACHMENTS': 'FIELD_INCLUDE_ATTACHMENTS_NONE', 'cannedScriptArgs_FIELD_FROM': '', 'cannedScriptArgs_FIELD_PREVIEW_ISSUE': '', 'cannedScript': 'com.onresolve.jira.groovy.canned.workflow.postfunctions.SendCustomEmail', 'id': '', 'Preview': 'Preview'} r = self._session.post( url, headers=self._options['headers'], data=payload) with open("/tmp/jira_email_user_%s.html" % user, "w") as f: f.write(r.text) def rename_user(self, old_user, new_user): """Rename a JIRA user. Current implementation relies on third party plugin but in the future it may use embedded JIRA functionality. :param old_user: string with username login :param new_user: string with username login """ if self._version >= (6, 0, 0): url = self._options['server'] + '/rest/api/latest/user' payload = { "name": new_user} params = { 'username': old_user} # raw displayName logging.debug("renaming %s" % self.user(old_user).emailAddress) r = self._session.put(url, params=params, data=json.dumps(payload)) else: # old implementation needed the ScripRunner plugin merge = "true" try: self.user(new_user) except Exception: merge = "false" url = self._options['server'] + '/secure/admin/groovy/CannedScriptRunner.jspa#result' payload = { "cannedScript": "com.onresolve.jira.groovy.canned.admin.RenameUser", "cannedScriptArgs_FIELD_FROM_USER_ID": old_user, "cannedScriptArgs_FIELD_TO_USER_ID": new_user, "cannedScriptArgs_FIELD_MERGE": merge, "id": "", "RunCanned": "Run"} # raw displayName logging.debug("renaming %s" % self.user(old_user).emailAddress) r = self._session.post( url, headers=self._options['headers'], data=payload) if r.status_code == 404: logging.error( "In order to be able to use rename_user() you need to install Script Runner plugin. " "See https://marketplace.atlassian.com/plugins/com.onresolve.jira.groovy.groovyrunner") return False if r.status_code != 200: logging.error(r.status_code) if re.compile("XSRF Security Token Missing").search(r.content): logging.fatal( "Reconfigure JIRA and disable XSRF in order to be able call this. See https://developer.atlassian.com/display/JIRADEV/Form+Token+Handling") return False with open("/tmp/jira_rename_user_%s_to%s.html" % (old_user, new_user), "w") as f: f.write(r.content) msg = r.status_code m = re.search("(.*)<\/span>", r.content) if m: msg = m.group(1) logging.error(msg) return False # Target user ID must exist already for a merge p = re.compile("type=\"hidden\" name=\"cannedScriptArgs_Hidden_output\" value=\"(.*?)\"\/>", re.MULTILINE | re.DOTALL) m = p.search(r.content) if m: h = html_parser.HTMLParser() msg = h.unescape(m.group(1)) logging.info(msg) # let's check if the user still exists try: self.user(old_user) except Exception as e: logging.error("User %s does not exists. %s", old_user, e) return msg logging.error(msg + '\n' + "User %s does still exists after rename, that's clearly a problem." % old_user) return False def delete_user(self, username): url = self._options['server'] + '/rest/api/latest/user/?username=%s' % username r = self._session.delete(url) if 200 <= r.status_code <= 299: return True else: logging.error(r.status_code) return False def deactivate_user(self, username): """Disable/deactivate the user.""" if self.deploymentType == 'Cloud': url = self._options['server'] + '/admin/rest/um/1/user/deactivate?username=' + username self._options['headers']['Content-Type'] = 'application/json' userInfo = {} else: url = self._options['server'] + '/secure/admin/user/EditUser.jspa' self._options['headers']['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8' user = self.user(username) userInfo = { 'inline': 'true', 'decorator': 'dialog', 'username': user.name, 'fullName': user.displayName, 'email': user.emailAddress, 'editName': user.name } try: r = self._session.post(url, headers=self._options['headers'], data=userInfo) if r.status_code == 200: return True else: logging.warning( 'Got response from deactivating %s: %s' % (username, r.status_code)) return r.status_code except Exception as e: print("Error Deactivating %s: %s" % (username, e)) def reindex(self, force=False, background=True): """Start jira re-indexing. Returns True if reindexing is in progress or not needed, or False. If you call reindex() without any parameters it will perform a backfround reindex only if JIRA thinks it should do it. :param force: reindex even if JIRA doesn'tt say this is needed, False by default. :param background: reindex inde background, slower but does not impact the users, defaults to True. """ # /secure/admin/IndexAdmin.jspa # /secure/admin/jira/IndexProgress.jspa?taskId=1 if background: indexingStrategy = 'background' else: indexingStrategy = 'stoptheworld' url = self._options['server'] + '/secure/admin/jira/IndexReIndex.jspa' r = self._session.get(url, headers=self._options['headers']) if r.status_code == 503: # logging.warning("JIRA returned 503, this could mean that a full reindex is in progress.") return 503 if not r.text.find("To perform the re-index now, please go to the") and force is False: return True if r.text.find('All issues are being re-indexed'): logging.warning("JIRA re-indexing is already running.") return True # still reindexing is considered still a success if r.text.find('To perform the re-index now, please go to the') or force: r = self._session.post(url, headers=self._options['headers'], params={"indexingStrategy": indexingStrategy, "reindex": "Re-Index"}) if r.text.find('All issues are being re-indexed') != -1: return True else: logging.error("Failed to reindex jira, probably a bug.") return False def backup(self, filename='backup.zip', attachments=False): """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished.""" if self.deploymentType == 'Cloud': url = self._options['server'] + '/rest/obm/1.0/runbackup' payload = json.dumps({"cbAttachments": attachments}) self._options['headers']['X-Requested-With'] = 'XMLHttpRequest' else: url = self._options['server'] + '/secure/admin/XmlBackup.jspa' payload = {'filename': filename} try: r = self._session.post(url, headers=self._options['headers'], data=payload) if r.status_code == 200: return True else: logging.warning( 'Got %s response from calling backup.' % r.status_code) return r.status_code except Exception as e: logging.error("I see %s", e) def backup_progress(self): """Return status of cloud backup as a dict. Is there a way to get progress for Server version? """ epoch_time = int(time.time() * 1000) if self.deploymentType == 'Cloud': url = self._options['server'] + '/rest/obm/1.0/getprogress?_=%i' % epoch_time else: logging.warning( 'This functionality is not available in Server version') return None r = self._session.get( url, headers=self._options['headers']) # This is weird. I used to get xml, but now I'm getting json try: return json.loads(r.text) except Exception: import defusedxml.ElementTree as etree progress = {} try: root = etree.fromstring(r.text) except etree.ParseError as pe: logging.warning('Unable to find backup info. You probably need to initiate a new backup. %s' % pe) return None for k in root.keys(): progress[k] = root.get(k) return progress def backup_complete(self): """Return boolean based on 'alternativePercentage' and 'size' returned from backup_progress (cloud only).""" if self.deploymentType != 'Cloud': logging.warning( 'This functionality is not available in Server version') return None status = self.backup_progress() perc_complete = int(re.search(r"\s([0-9]*)\s", status['alternativePercentage']).group(1)) file_size = int(status['size']) return perc_complete >= 100 and file_size > 0 def backup_download(self, filename=None): """Download backup file from WebDAV (cloud only).""" if self.deploymentType != 'Cloud': logging.warning( 'This functionality is not available in Server version') return None remote_file = self.backup_progress()['fileName'] local_file = filename or remote_file url = self._options['server'] + '/webdav/backupmanager/' + remote_file try: logging.debug('Writing file to %s' % local_file) with open(local_file, 'wb') as file: try: resp = self._session.get(url, headers=self._options['headers'], stream=True) except Exception: raise JIRAError() if not resp.ok: logging.error("Something went wrong with download: %s" % resp.text) raise JIRAError(resp.text) for block in resp.iter_content(1024): file.write(block) except JIRAError as je: logging.error('Unable to access remote backup file: %s' % je) except IOError as ioe: logging.error(ioe) return None def current_user(self): if not hasattr(self, '_serverInfo') or 'username' not in self._serverInfo: url = self._get_url('serverInfo') r = self._session.get(url, headers=self._options['headers']) r_json = json_loads(r) if 'x-ausername' in r.headers: r_json['username'] = r.headers['x-ausername'] else: r_json['username'] = None self._serverInfo = r_json # del r_json['self'] # this isn't really an addressable resource return self._serverInfo['username'] def delete_project(self, pid): """Delete project from Jira. :param str pid: JIRA projectID or Project or slug :returns bool: True if project was deleted :raises JIRAError: If project not found or not enough permissions :raises ValueError: If pid parameter is not Project, slug or ProjectID """ # allows us to call it with Project objects if hasattr(pid, 'id'): pid = pid.id # Check if pid is a number - then we assume that it is # projectID try: str(int(pid)) == pid except Exception as e: # pid looks like a slug, lets verify that r_json = self._get_json('project') for e in r_json: if e['key'] == pid or e['name'] == pid: pid = e['id'] break else: # pid is not a Project # not a projectID and not a slug - we raise error here raise ValueError('Parameter pid="%s" is not a Project, ' 'projectID or slug' % pid) uri = '/rest/api/2/project/%s' % pid url = self._options['server'] + uri try: r = self._session.delete( url, headers={'Content-Type': 'application/json'} ) except JIRAError as je: if '403' in str(je): raise JIRAError('Not enough permissions to delete project') if '404' in str(je): raise JIRAError('Project not found in Jira') raise je if r.status_code == 204: return True def _gain_sudo_session(self, options, destination): url = self._options['server'] + '/secure/admin/WebSudoAuthenticate.jspa' if not self._session.auth: self._session.auth = get_netrc_auth(url) payload = { 'webSudoPassword': self._session.auth[1], 'webSudoDestination': destination, 'webSudoIsPost': 'true'} payload.update(options) return self._session.post( url, headers=CaseInsensitiveDict({'content-type': 'application/x-www-form-urlencoded'}), data=payload) def create_project(self, key, name=None, assignee=None, type="Software", template_name=None): """Key is mandatory and has to match JIRA project key requirements, usually only 2-10 uppercase characters. If name is not specified it will use the key value. If assignee is not specified it will use current user. Parameter template_name is used to create a project based on one of the existing project templates. If template_name is not specified, then it should use one of the default values. The returned value should evaluate to False if it fails otherwise it will be the new project id. """ if assignee is None: assignee = self.current_user() if name is None: name = key url = self._options['server'] + \ '/rest/project-templates/latest/templates' r = self._session.get(url) j = json_loads(r) # https://confluence.atlassian.com/jirakb/creating-a-project-via-rest-based-on-jira-default-schemes-744325852.html template_key = 'com.atlassian.jira-legacy-project-templates:jira-blank-item' templates = [] for template in _get_template_list(j): templates.append(template['name']) if template['name'] in ['JIRA Classic', 'JIRA Default Schemes', 'Basic software development', template_name]: template_key = template['projectTemplateModuleCompleteKey'] break payload = {'name': name, 'key': key, 'keyEdited': 'false', # 'projectTemplate': 'com.atlassian.jira-core-project-templates:jira-issuetracking', # 'permissionScheme': '', 'projectTemplateWebItemKey': template_key, 'projectTemplateModuleKey': template_key, 'lead': assignee, # 'assigneeType': '2', } if self._version[0] > 6: # JIRA versions before 7 will throw an error if we specify type parameter payload['type'] = type headers = CaseInsensitiveDict( {'Content-Type': 'application/x-www-form-urlencoded'}) r = self._session.post(url, data=payload, headers=headers) if r.status_code == 200: r_json = json_loads(r) return r_json f = tempfile.NamedTemporaryFile( suffix='.html', prefix='python-jira-error-create-project-', delete=False) f.write(r.text) if self.logging: logging.error( "Unexpected result while running create project. Server response saved in %s for further investigation [HTTP response=%s]." % ( f.name, r.status_code)) return False def add_user(self, username, email, directoryId=1, password=None, fullname=None, notify=False, active=True, ignore_existing=False): """Create a new JIRA user. :param username: the username of the new user :type username: ``str`` :param email: email address of the new user :type email: ``str`` :param directoryId: the directory ID the new user should be a part of :type directoryId: ``int`` :param password: Optional, the password for the new user :type password: ``str`` :param fullname: Optional, the full name of the new user :type fullname: ``str`` :param notify: Whether or not to send a notification to the new user :type notify: ``bool`` :param active: Whether or not to make the new user active upon creation :type active: ``bool`` """ if not fullname: fullname = username # TODO(ssbarnea): default the directoryID to the first directory in jira instead # of 1 which is the internal one. url = self._options['server'] + '/rest/api/latest/user' # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 x = OrderedDict() x['displayName'] = fullname x['emailAddress'] = email x['name'] = username if password: x['password'] = password if notify: x['notification'] = 'True' payload = json.dumps(x) try: self._session.post(url, data=payload) except JIRAError as e: err = e.response.json()['errors'] if 'username' in err and err['username'] == 'A user with that username already exists.' and ignore_existing: return True raise e return True def add_user_to_group(self, username, group): """Add a user to an existing group. :param username: Username that will be added to specified group. :param group: Group that the user will be added to. :return: json response from Jira server for success or a value that evaluates as False in case of failure. """ url = self._options['server'] + '/rest/api/latest/group/user' x = {'groupname': group} y = {'name': username} payload = json.dumps(y) r = json_loads(self._session.post(url, params=x, data=payload)) if 'name' not in r or r['name'] != group: return False else: return r def remove_user_from_group(self, username, groupname): """Remove a user from a group. :param username: The user to remove from the group. :param groupname: The group that the user will be removed from. """ url = self._options['server'] + '/rest/api/latest/group/user' x = {'groupname': groupname, 'username': username} self._session.delete(url, params=x) return True # Experimental # Experimental support for iDalko Grid, expect API to change as it's using private APIs currently # https://support.idalko.com/browse/IGRID-1017 def get_igrid(self, issueid, customfield, schemeid): url = self._options['server'] + '/rest/idalko-igrid/1.0/datagrid/data' if str(customfield).isdigit(): customfield = "customfield_%s" % customfield params = { # '_mode':'view', # 'validate':True, # '_search':False, # 'rows':100, # 'page':1, # 'sidx':'DEFAULT', # 'sord':'asc' '_issueId': issueid, '_fieldId': customfield, '_confSchemeId': schemeid} r = self._session.get( url, headers=self._options['headers'], params=params) return json_loads(r) # Jira Agile specific methods (GreenHopper) """ Define the functions that interact with GreenHopper. """ @translate_resource_args def boards(self, startAt=0, maxResults=50, type=None, name=None): """Get a list of board resources. :param startAt: The starting index of the returned boards. Base index: 0. :param maxResults: The maximum number of boards to return per page. Default: 50 :param type: Filters results to boards of the specified type. Valid values: scrum, kanban. :param name: Filters results to boards that match or partially match the specified name. :rtype: ResultList[Board] When old GreenHopper private API is used, paging is not enabled and all parameters are ignored. """ params = {} if type: params['type'] = type if name: params['name'] = name if self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: # Old, private API did not support pagination, all records were present in response, # and no parameters were supported. if startAt or maxResults or params: warnings.warn('Old private GreenHopper API is used, all parameters will be ignored.', Warning) r_json = self._get_json('rapidviews/list', base=self.AGILE_BASE_URL) boards = [Board(self._options, self._session, raw_boards_json) for raw_boards_json in r_json['views']] return ResultList(boards, 0, len(boards), len(boards), True) else: return self._fetch_pages(Board, 'values', 'board', startAt, maxResults, params, base=self.AGILE_BASE_URL) @translate_resource_args def sprints(self, board_id, extended=False, startAt=0, maxResults=50, state=None): """Get a list of sprint GreenHopperResources. :param board_id: the board to get sprints from :param extended: Used only by old GreenHopper API to fetch additional information like startDate, endDate, completeDate, much slower because it requires an additional requests for each sprint. New JIRA Agile API always returns this information without a need for additional requests. :param startAt: the index of the first sprint to return (0 based) :param maxResults: the maximum number of sprints to return :param state: Filters results to sprints in specified states. Valid values: future, active, closed. You can define multiple states separated by commas :rtype: dict :return: (content depends on API version, but always contains id, name, state, startDate and endDate) When old GreenHopper private API is used, paging is not enabled, and `startAt`, `maxResults` and `state` parameters are ignored. """ params = {} if state: if isinstance(state, string_types): state = state.split(",") params['state'] = state if self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: r_json = self._get_json('sprintquery/%s?includeHistoricSprints=true&includeFutureSprints=true' % board_id, base=self.AGILE_BASE_URL) if params: warnings.warn('Old private GreenHopper API is used, parameters %s will be ignored.' % params, Warning) if extended: sprints = [Sprint(self._options, self._session, self.sprint_info(None, raw_sprints_json['id'])) for raw_sprints_json in r_json['sprints']] else: sprints = [Sprint(self._options, self._session, raw_sprints_json) for raw_sprints_json in r_json['sprints']] return ResultList(sprints, 0, len(sprints), len(sprints), True) else: return self._fetch_pages(Sprint, 'values', 'board/%s/sprint' % board_id, startAt, maxResults, params, self.AGILE_BASE_URL) def sprints_by_name(self, id, extended=False): sprints = {} for s in self.sprints(id, extended=extended): if s.name not in sprints: sprints[s.name] = s.raw else: raise Exception return sprints def update_sprint(self, id, name=None, startDate=None, endDate=None, state=None): payload = {} if name: payload['name'] = name if startDate: payload['startDate'] = startDate if endDate: payload['endDate'] = endDate if state: if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH: raise NotImplementedError('Public JIRA API does not support state update') payload['state'] = state url = self._get_url('sprint/%s' % id, base=self.AGILE_BASE_URL) r = self._session.put( url, data=json.dumps(payload)) return json_loads(r) def incompletedIssuesEstimateSum(self, board_id, sprint_id): """Return the total incompleted points this sprint.""" return self._get_json('rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s' % (board_id, sprint_id), base=self.AGILE_BASE_URL)['contents']['incompletedIssuesEstimateSum']['value'] def removed_issues(self, board_id, sprint_id): """Return the completed issues for the sprint.""" r_json = self._get_json('rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s' % (board_id, sprint_id), base=self.AGILE_BASE_URL) issues = [Issue(self._options, self._session, raw_issues_json) for raw_issues_json in r_json['contents']['puntedIssues']] return issues def removedIssuesEstimateSum(self, board_id, sprint_id): """Return the total incompleted points this sprint.""" return self._get_json('rapid/charts/sprintreport?rapidViewId=%s&sprintId=%s' % (board_id, sprint_id), base=self.AGILE_BASE_URL)['contents']['puntedIssuesEstimateSum']['value'] # TODO(ssbarnea): remove sprint_info() method, sprint() method suit the convention more def sprint_info(self, board_id, sprint_id): """Return the information about a sprint. :param board_id: the board retrieving issues from. Deprecated and ignored. :param sprint_id: the sprint retrieving issues from """ sprint = Sprint(self._options, self._session) sprint.find(sprint_id) return sprint.raw def sprint(self, id): sprint = Sprint(self._options, self._session) sprint.find(id) return sprint # TODO(ssbarnea): remove this as we do have Board.delete() def delete_board(self, id): """Delete an agile board.""" board = Board(self._options, self._session, raw={'id': id}) board.delete() def create_board(self, name, project_ids, preset="scrum"): """Create a new board for the ``project_ids``. :param name: name of the board :param project_ids: the projects to create the board in :param preset: what preset to use for this board :type preset: 'kanban', 'scrum', 'diy' """ if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH: raise NotImplementedError('JIRA Agile Public API does not support this request') payload = {} if isinstance(project_ids, string_types): ids = [] for p in project_ids.split(','): ids.append(self.project(p).id) project_ids = ','.join(ids) payload['name'] = name if isinstance(project_ids, string_types): project_ids = project_ids.split(',') payload['projectIds'] = project_ids payload['preset'] = preset url = self._get_url( 'rapidview/create/presets', base=self.AGILE_BASE_URL) r = self._session.post( url, data=json.dumps(payload)) raw_issue_json = json_loads(r) return Board(self._options, self._session, raw=raw_issue_json) def create_sprint(self, name, board_id, startDate=None, endDate=None): """Create a new sprint for the ``board_id``. :param name: name of the sprint :param board_id: the board to add the sprint to """ payload = {'name': name} if startDate: payload["startDate"] = startDate if endDate: payload["endDate"] = endDate if self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: url = self._get_url('sprint/%s' % board_id, base=self.AGILE_BASE_URL) r = self._session.post(url) raw_issue_json = json_loads(r) """ now r contains something like: { "id": 742, "name": "Sprint 89", "state": "FUTURE", "linkedPagesCount": 0, "startDate": "None", "endDate": "None", "completeDate": "None", "remoteLinks": [] }""" url = self._get_url( 'sprint/%s' % raw_issue_json['id'], base=self.AGILE_BASE_URL) r = self._session.put( url, data=json.dumps(payload)) raw_issue_json = json_loads(r) else: url = self._get_url('sprint', base=self.AGILE_BASE_URL) payload['originBoardId'] = board_id r = self._session.post(url, data=json.dumps(payload)) raw_issue_json = json_loads(r) return Sprint(self._options, self._session, raw=raw_issue_json) def add_issues_to_sprint(self, sprint_id, issue_keys): """Add the issues in ``issue_keys`` to the ``sprint_id``. The sprint must be started but not completed. If a sprint was completed, then have to also edit the history of the issue so that it was added to the sprint before it was completed, preferably before it started. A completed sprint's issues also all have a resolution set before the completion date. If a sprint was not started, then have to edit the marker and copy the rank of each issue too. :param sprint_id: the sprint to add issues to :param issue_keys: the issues to add to the sprint """ if self._options['agile_rest_path'] == GreenHopperResource.AGILE_BASE_REST_PATH: url = self._get_url('sprint/%s/issue' % sprint_id, base=self.AGILE_BASE_URL) payload = {'issues': issue_keys} try: self._session.post(url, data=json.dumps(payload)) except JIRAError as e: if e.status_code == 404: warnings.warn('Status code 404 may mean, that too old JIRA Agile version is installed.' ' At least version 6.7.10 is required.') raise elif self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: # In old, private API the function does not exist anymore and we need to use # issue.update() to perform this operation # Workaround based on https://answers.atlassian.com/questions/277651/jira-agile-rest-api-example # Get the customFieldId for "Sprint" sprint_field_name = "Sprint" sprint_field_id = [f['schema']['customId'] for f in self.fields() if f['name'] == sprint_field_name][0] data = {'idOrKeys': issue_keys, 'customFieldId': sprint_field_id, 'sprintId': sprint_id, 'addToBacklog': False} url = self._get_url('sprint/rank', base=self.AGILE_BASE_URL) return self._session.put(url, data=json.dumps(data)) else: raise NotImplementedError('No API for adding issues to sprint for agile_rest_path="%s"' % self._options['agile_rest_path']) def add_issues_to_epic(self, epic_id, issue_keys, ignore_epics=True): """Add the issues in ``issue_keys`` to the ``epic_id``. :param epic_id: the epic to add issues to :param issue_keys: the issues to add to the epic :param ignore_epics: ignore any issues listed in ``issue_keys`` that are epics """ if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH: # TODO(ssbarnea): simulate functionality using issue.update()? raise NotImplementedError('JIRA Agile Public API does not support this request') data = {} data['issueKeys'] = issue_keys data['ignoreEpics'] = ignore_epics url = self._get_url('epics/%s/add' % epic_id, base=self.AGILE_BASE_URL) return self._session.put( url, data=json.dumps(data)) # TODO(ssbarnea): Both GreenHopper and new JIRA Agile API support moving more than one issue. def rank(self, issue, next_issue): """Rank an issue before another using the default Ranking field, the one named 'Rank'. :param issue: issue key of the issue to be ranked before the second one. :param next_issue: issue key of the second issue. """ if not self._rank: for field in self.fields(): if field['name'] == 'Rank': if field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-lexo-rank": self._rank = field['schema']['customId'] break elif field['schema']['custom'] == "com.pyxis.greenhopper.jira:gh-global-rank": # Obsolete since JIRA v6.3.13.1 self._rank = field['schema']['customId'] if self._options['agile_rest_path'] == GreenHopperResource.AGILE_BASE_REST_PATH: url = self._get_url('issue/rank', base=self.AGILE_BASE_URL) payload = {'issues': [issue], 'rankBeforeIssue': next_issue, 'rankCustomFieldId': self._rank} try: return self._session.put(url, data=json.dumps(payload)) except JIRAError as e: if e.status_code == 404: warnings.warn('Status code 404 may mean, that too old JIRA Agile version is installed.' ' At least version 6.7.10 is required.') raise elif self._options['agile_rest_path'] == GreenHopperResource.GREENHOPPER_REST_PATH: # {"issueKeys":["ANERDS-102"],"rankBeforeKey":"ANERDS-94","rankAfterKey":"ANERDS-7","customFieldId":11431} data = { "issueKeys": [issue], "rankBeforeKey": next_issue, "customFieldId": self._rank} url = self._get_url('rank', base=self.AGILE_BASE_URL) return self._session.put(url, data=json.dumps(data)) else: raise NotImplementedError('No API for ranking issues for agile_rest_path="%s"' % self._options['agile_rest_path']) def move_to_backlog(self, issue_keys): """Move issues in ``issue_keys`` to the backlog, removing them from all sprints that have not been completed. :param issue_keys: the issues to move to the backlog """ if self._options['agile_rest_path'] == GreenHopperResource.AGILE_BASE_REST_PATH: url = self._get_url('backlog/issue', base=self.AGILE_BASE_URL) payload = {'issues': issue_keys} try: self._session.post(url, data=json.dumps(payload)) except JIRAError as e: if e.status_code == 404: warnings.warn('Status code 404 may mean, that too old JIRA Agile version is installed.' ' At least version 6.7.10 is required.') raise else: raise NotImplementedError('No API for moving issues to backlog for agile_rest_path="%s"' % self._options['agile_rest_path']) class GreenHopper(JIRA): def __init__(self, options=None, basic_auth=None, oauth=None, async=None): warnings.warn( "GreenHopper() class is deprecated, just use JIRA() instead.", DeprecationWarning) JIRA.__init__( self, options=options, basic_auth=basic_auth, oauth=oauth, async=async) jira-1.0.10/jira/config.py000066400000000000000000000064311304741173700152750ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- """ This module allows people to keep their jira server credentials outside their script, in a configuration file that is not saved in the source control. Also, this simplifies the scripts by not having to write the same initialization code for each script. """ import logging import os import sys try: import configparser except ImportError: from six.moves import configparser from jira.client import JIRA def get_jira(profile=None, url="http://localhost:2990", username="admin", password="admin", appid=None, autofix=False, verify=True): """Return a JIRA object by loading the connection details from the `config.ini` file. :param profile: The name of the section from config.ini file that stores server config url/username/password :param url: URL of the Jira server :param username: username to use for authentication :param password: password to use for authentication :param verify: boolean indicating whether SSL certificates should be verified :return: JIRA -- an instance to a JIRA object. :raises: EnvironmentError Usage: >>> from jira.config import get_jira >>> >>> jira = get_jira(profile='jira') Also create a `config.ini` like this and put it in current directory, user home directory or PYTHONPATH. .. code-block:: none [jira] url=https://jira.atlassian.com # only the `url` is mandatory user=... pass=... appid=... verify=... """ def findfile(path): """Find the file named path in the sys.path. Returns the full path name if found, None if not found """ paths = ['.', os.path.expanduser('~')] paths.extend(sys.path) for dirname in paths: possible = os.path.abspath(os.path.join(dirname, path)) if os.path.isfile(possible): return possible return None config = configparser.ConfigParser(defaults={'user': None, 'pass': None, 'appid': appid, 'autofix': autofix, 'verify': 'yes' if verify else 'no'}) config_file = findfile('config.ini') if config_file: logging.debug("Found %s config file" % config_file) if not profile: if config_file: config.read(config_file) try: profile = config.get('general', 'default-jira-profile') except configparser.NoOptionError: pass if profile: if config_file: config.read(config_file) url = config.get(profile, 'url') username = config.get(profile, 'user') password = config.get(profile, 'pass') appid = config.get(profile, 'appid') autofix = config.get(profile, 'autofix') verify = config.getboolean(profile, 'verify') else: raise EnvironmentError( "%s was not able to locate the config.ini file in current directory, user home directory or PYTHONPATH." % __name__) options = JIRA.DEFAULT_OPTIONS options['server'] = url options['autofix'] = autofix options['appid'] = appid options['verify'] = verify return JIRA(options=options, basic_auth=(username, password)) # self.jira.config.debug = debug jira-1.0.10/jira/exceptions.py000066400000000000000000000043131304741173700162060ustar00rootroot00000000000000import os import tempfile class JIRAError(Exception): """General error raised for all problems in operation of the client.""" log_to_tempfile = True if 'TRAVIS' in os.environ: log_to_tempfile = False # Travis is keeping only the console log. def __init__(self, status_code=None, text=None, url=None, request=None, response=None, **kwargs): self.status_code = status_code self.text = text self.url = url self.request = request self.response = response self.headers = kwargs.get('headers', None) self.log_to_tempfile = False self.travis = False if 'PYJIRA_LOG_TO_TEMPFILE' in os.environ: self.log_to_tempfile = True if 'TRAVIS' in os.environ: self.travis = True def __str__(self): """Return a string representation of the error.""" t = "JiraError HTTP %s" % self.status_code if self.url: t += " url: %s" % self.url details = "" if self.request is not None and hasattr(self.request, 'headers'): details += "\n\trequest headers = %s" % self.request.headers if self.request is not None and hasattr(self.request, 'text'): details += "\n\trequest text = %s" % self.request.text if self.response is not None and hasattr(self.response, 'headers'): details += "\n\tresponse headers = %s" % self.response.headers if self.response is not None and hasattr(self.response, 'text'): details += "\n\tresponse text = %s" % self.response.text # separate logging for Travis makes sense. if self.travis: if self.text: t += "\n\ttext: %s" % self.text t += details # Only log to tempfile if the option is set. elif self.log_to_tempfile: fd, file_name = tempfile.mkstemp(suffix='.tmp', prefix='jiraerror-') with open(file_name, "w") as f: t += " details: %s" % file_name f.write(details) # Otherwise, just return the error as usual else: if self.text: t += "\n\ttext: %s" % self.text t += "\n\t" + details return t jira-1.0.10/jira/jirashell.py000066400000000000000000000217641304741173700160130ustar00rootroot00000000000000#!/usr/bin/env python """Starts an interactive JIRA session in an ipython terminal. Script arguments support changing the server and a persistent authentication over HTTP BASIC. """ try: import configparser except ImportError: from six.moves import configparser from six.moves import input from six.moves.urllib.parse import parse_qsl import argparse from getpass import getpass from jira import __version__ from jira import JIRA from oauthlib.oauth1 import SIGNATURE_RSA import os import requests from requests_oauthlib import OAuth1 from sys import exit import webbrowser CONFIG_PATH = os.path.join( os.path.expanduser('~'), '.jira-python', 'jirashell.ini') def oauth_dance(server, consumer_key, key_cert_data, print_tokens=False, verify=None): if verify is None: verify = server.startswith('https') # step 1: get request tokens oauth = OAuth1( consumer_key, signature_method=SIGNATURE_RSA, rsa_key=key_cert_data) r = requests.post( server + '/plugins/servlet/oauth/request-token', verify=verify, auth=oauth) request = dict(parse_qsl(r.text)) request_token = request['oauth_token'] request_token_secret = request['oauth_token_secret'] if print_tokens: print("Request tokens received.") print(" Request token: {}".format(request_token)) print(" Request token secret: {}".format(request_token_secret)) # step 2: prompt user to validate auth_url = '{}/plugins/servlet/oauth/authorize?oauth_token={}'.format( server, request_token) if print_tokens: print( "Please visit this URL to authorize the OAuth request:\n\t{}".format(auth_url)) else: webbrowser.open_new(auth_url) print( "Your browser is opening the OAuth authorization for this client session.") approved = input( 'Have you authorized this program to connect on your behalf to {}? (y/n)'.format(server)) if approved.lower() != 'y': exit( 'Abandoning OAuth dance. Your partner faceplants. The audience boos. You feel shame.') # step 3: get access tokens for validated user oauth = OAuth1(consumer_key, signature_method=SIGNATURE_RSA, rsa_key=key_cert_data, resource_owner_key=request_token, resource_owner_secret=request_token_secret ) r = requests.post( server + '/plugins/servlet/oauth/access-token', verify=verify, auth=oauth) access = dict(parse_qsl(r.text)) if print_tokens: print("Access tokens received.") print(" Access token: {}".format(access['oauth_token'])) print(" Access token secret: {}".format( access['oauth_token_secret'])) return { 'access_token': access['oauth_token'], 'access_token_secret': access['oauth_token_secret'], 'consumer_key': consumer_key, 'key_cert': key_cert_data} def process_config(): if not os.path.exists(CONFIG_PATH): return {}, {}, {} parser = configparser.ConfigParser() try: parser.read(CONFIG_PATH) except configparser.ParsingError as err: print("Couldn't read config file at path: {}\n{}".format( CONFIG_PATH, err)) raise if parser.has_section('options'): options = {} for option, value in parser.items('options'): if option in ("verify", "async"): value = parser.getboolean('options', option) options[option] = value else: options = {} if parser.has_section('basic_auth'): basic_auth = dict(parser.items('basic_auth')) else: basic_auth = {} if parser.has_section('oauth'): oauth = {} for option, value in parser.items('oauth'): if option in ("oauth_dance", "print_tokens"): value = parser.getboolean('oauth', option) oauth[option] = value else: oauth = {} return options, basic_auth, oauth def process_command_line(): parser = argparse.ArgumentParser( description='Start an interactive JIRA shell with the REST API.') jira_group = parser.add_argument_group('JIRA server connection options') jira_group.add_argument('-s', '--server', help='The JIRA instance to connect to, including context path.') jira_group.add_argument('-r', '--rest-path', help='The root path of the REST API to use.') jira_group.add_argument('-v', '--rest-api-version', help='The version of the API under the specified name.') jira_group.add_argument('--no-verify', action='store_true', help='do not verify the ssl certificate') basic_auth_group = parser.add_argument_group('BASIC auth options') basic_auth_group.add_argument('-u', '--username', help='The username to connect to this JIRA instance with.') basic_auth_group.add_argument('-p', '--password', help='The password associated with this user.') basic_auth_group.add_argument('-P', '--prompt-for-password', action='store_true', help='Prompt for the password at the command line.') oauth_group = parser.add_argument_group('OAuth options') oauth_group.add_argument('-od', '--oauth-dance', action='store_true', help='Start a 3-legged OAuth authentication dance with JIRA.') oauth_group.add_argument('-ck', '--consumer-key', help='OAuth consumer key.') oauth_group.add_argument('-k', '--key-cert', help='Private key to sign OAuth requests with (should be the pair of the public key\ configured in the JIRA application link)') oauth_group.add_argument('-pt', '--print-tokens', action='store_true', help='Print the negotiated OAuth tokens as they are retrieved.') oauth_already_group = parser.add_argument_group( 'OAuth options for already-authenticated access tokens') oauth_already_group.add_argument('-at', '--access-token', help='OAuth access token for the user.') oauth_already_group.add_argument('-ats', '--access-token-secret', help='Secret for the OAuth access token.') args = parser.parse_args() options = {} if args.server: options['server'] = args.server if args.rest_path: options['rest_path'] = args.rest_path if args.rest_api_version: options['rest_api_version'] = args.rest_api_version options['verify'] = True if args.no_verify: options['verify'] = False if args.prompt_for_password: args.password = getpass() basic_auth = {} if args.username: basic_auth['username'] = args.username if args.password: basic_auth['password'] = args.password key_cert_data = None if args.key_cert: with open(args.key_cert, 'r') as key_cert_file: key_cert_data = key_cert_file.read() oauth = {} if args.oauth_dance: oauth = { 'oauth_dance': True, 'consumer_key': args.consumer_key, 'key_cert': key_cert_data, 'print_tokens': args.print_tokens} elif args.access_token and args.access_token_secret and args.consumer_key and args.key_cert: oauth = { 'access_token': args.access_token, 'oauth_dance': False, 'access_token_secret': args.access_token_secret, 'consumer_key': args.consumer_key, 'key_cert': key_cert_data} return options, basic_auth, oauth def get_config(): options, basic_auth, oauth = process_config() cmd_options, cmd_basic_auth, cmd_oauth = process_command_line() options.update(cmd_options) basic_auth.update(cmd_basic_auth) oauth.update(cmd_oauth) return options, basic_auth, oauth def main(): try: get_ipython except NameError: pass else: exit("Running ipython inside ipython isn't supported. :(") options, basic_auth, oauth = get_config() if basic_auth: basic_auth = (basic_auth['username'], basic_auth['password']) if oauth.get('oauth_dance') is True: oauth = oauth_dance( options['server'], oauth['consumer_key'], oauth['key_cert'], oauth['print_tokens'], options['verify']) elif not all((oauth.get('access_token'), oauth.get('access_token_secret'), oauth.get('consumer_key'), oauth.get('key_cert'))): oauth = None jira = JIRA(options=options, basic_auth=basic_auth, oauth=oauth) from IPython.frontend.terminal.embed import InteractiveShellEmbed ipshell = InteractiveShellEmbed( banner1='') ipshell("*** JIRA shell active; client is in 'jira'." ' Press Ctrl-D to exit.') if __name__ == '__main__': status = main() exit(status) jira-1.0.10/jira/resilientsession.py000066400000000000000000000147341304741173700174370ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals import json import logging try: # Python 2.7+ from logging import NullHandler except ImportError: class NullHandler(logging.Handler): def emit(self, record): pass import random from requests.exceptions import ConnectionError from requests import Session import time from jira.exceptions import JIRAError logging.getLogger('jira').addHandler(NullHandler()) def raise_on_error(r, verb='???', **kwargs): request = kwargs.get('request', None) # headers = kwargs.get('headers', None) if r is None: raise JIRAError(None, **kwargs) if r.status_code >= 400: error = '' if r.status_code == 403 and "x-authentication-denied-reason" in r.headers: error = r.headers["x-authentication-denied-reason"] elif r.text: try: response = json.loads(r.text) if 'message' in response: # JIRA 5.1 errors error = response['message'] elif 'errorMessages' in response and len(response['errorMessages']) > 0: # JIRA 5.0.x error messages sometimes come wrapped in this array # Sometimes this is present but empty errorMessages = response['errorMessages'] if isinstance(errorMessages, (list, tuple)): error = errorMessages[0] else: error = errorMessages elif 'errors' in response and len(response['errors']) > 0: # JIRA 6.x error messages are found in this array. error_list = response['errors'].values() error = ", ".join(error_list) else: error = r.text except ValueError: error = r.text raise JIRAError( r.status_code, error, r.url, request=request, response=r, **kwargs) # for debugging weird errors on CI if r.status_code not in [200, 201, 202, 204]: raise JIRAError(r.status_code, request=request, response=r, **kwargs) # testing for the WTH bug exposed on # https://answers.atlassian.com/questions/11457054/answers/11975162 if r.status_code == 200 and len(r.content) == 0 \ and 'X-Seraph-LoginReason' in r.headers \ and 'AUTHENTICATED_FAILED' in r.headers['X-Seraph-LoginReason']: pass class ResilientSession(Session): """This class is supposed to retry requests that do return temporary errors. At this moment it supports: 502, 503, 504 """ def __init__(self, timeout=None): self.max_retries = 3 self.timeout = timeout super(ResilientSession, self).__init__() # Indicate our preference for JSON to avoid https://bitbucket.org/bspeakmon/jira-python/issue/46 and https://jira.atlassian.com/browse/JRA-38551 self.headers.update({"Accept": "application/json,*.*;q=0.9"}) def __recoverable(self, response, url, request, counter=1): msg = response if isinstance(response, ConnectionError): logging.warning("Got ConnectionError [%s] errno:%s on %s %s\n%s\%s" % ( response, response.errno, request, url, vars(response), response.__dict__)) if hasattr(response, 'status_code'): if response.status_code in [502, 503, 504, 401]: # 401 UNAUTHORIZED still randomly returned by Atlassian Cloud as of 2017-01-16 msg = "%s %s" % (response.status_code, response.reason) elif not (response.status_code == 200 and len(response.content) == 0 and 'X-Seraph-LoginReason' in response.headers and 'AUTHENTICATED_FAILED' in response.headers['X-Seraph-LoginReason']): return False else: msg = "Atlassian's bug https://jira.atlassian.com/browse/JRA-41559" # Exponential backoff with full jitter. delay = min(60, 10 * 2 ** counter) * random.random() logging.warning("Got recoverable error from %s %s, will retry [%s/%s] in %ss. Err: %s" % ( request, url, counter, self.max_retries, delay, msg)) time.sleep(delay) return True def __verb(self, verb, url, retry_data=None, **kwargs): d = self.headers.copy() d.update(kwargs.get('headers', {})) kwargs['headers'] = d # if we pass a dictionary as the 'data' we assume we want to send json # data data = kwargs.get('data', {}) if isinstance(data, dict): data = json.dumps(data) retry_number = 0 while retry_number <= self.max_retries: response = None exception = None try: method = getattr(super(ResilientSession, self), verb.lower()) response = method(url, timeout=self.timeout, **kwargs) if response.status_code == 200: return response except ConnectionError as e: logging.warning( "%s while doing %s %s [%s]" % (e, verb.upper(), url, kwargs)) exception = e retry_number += 1 if retry_number <= self.max_retries: response_or_exception = response if response is not None else exception if self.__recoverable(response_or_exception, url, verb.upper(), retry_number): if retry_data: # if data is a stream, we cannot just read again from it, # retry_data() will give us a new stream with the data kwargs['data'] = retry_data() continue else: break if exception is not None: raise exception raise_on_error(response, verb=verb, **kwargs) return response def get(self, url, **kwargs): return self.__verb('GET', url, **kwargs) def post(self, url, **kwargs): return self.__verb('POST', url, **kwargs) def put(self, url, **kwargs): return self.__verb('PUT', url, **kwargs) def delete(self, url, **kwargs): return self.__verb('DELETE', url, **kwargs) def head(self, url, **kwargs): return self.__verb('HEAD', url, **kwargs) def patch(self, url, **kwargs): return self.__verb('PATCH', url, **kwargs) def options(self, url, **kwargs): return self.__verb('OPTIONS', url, **kwargs) jira-1.0.10/jira/resources.py000066400000000000000000000776251304741173700160570ustar00rootroot00000000000000# -*- coding: utf-8 -*- from __future__ import unicode_literals from __future__ import print_function """ This module implements the Resource classes that translate JSON from JIRA REST resources into usable objects. """ import logging import re import time try: # Python 2.7+ from logging import NullHandler except ImportError: class NullHandler(logging.Handler): def emit(self, record): pass import json from six import iteritems from six import string_types from six import text_type from jira.utils import CaseInsensitiveDict from jira.utils import json_loads from jira.utils import threaded_requests __all__ = ( 'Resource', 'Issue', 'Comment', 'Project', 'Attachment', 'Component', 'Dashboard', 'Filter', 'Votes', 'Watchers', 'Worklog', 'IssueLink', 'IssueLinkType', 'IssueType', 'Priority', 'Version', 'Role', 'Resolution', 'SecurityLevel', 'Status', 'User', 'CustomFieldOption', 'RemoteLink' ) logging.getLogger('jira').addHandler(NullHandler()) def get_error_list(r): error_list = [] if r.status_code >= 400: if r.status_code == 403 and "x-authentication-denied-reason" in r.headers: error_list = [r.headers["x-authentication-denied-reason"]] elif r.text: try: response = json_loads(r) if 'message' in response: # JIRA 5.1 errors error_list = [response['message']] elif 'errorMessages' in response and len(response['errorMessages']) > 0: # JIRA 5.0.x error messages sometimes come wrapped in this array # Sometimes this is present but empty errorMessages = response['errorMessages'] if isinstance(errorMessages, (list, tuple)): error_list = errorMessages else: error_list = [errorMessages] elif 'errors' in response and len(response['errors']) > 0: # JIRA 6.x error messages are found in this array. error_list = response['errors'].values() else: error_list = [r.text] except ValueError: error_list = [r.text] return error_list class Resource(object): """Models a URL-addressable resource in the JIRA REST API. All Resource objects provide the following: ``find()`` -- get a resource from the server and load it into the current object (though clients should use the methods in the JIRA class instead of this method directly) ``update()`` -- changes the value of this resource on the server and returns a new resource object for it ``delete()`` -- deletes this resource from the server ``self`` -- the URL of this resource on the server ``raw`` -- dict of properties parsed out of the JSON response from the server Subclasses will implement ``update()`` and ``delete()`` as appropriate for the specific resource. All Resources have a resource path of the form: * ``issue`` * ``project/{0}`` * ``issue/{0}/votes`` * ``issue/{0}/comment/{1}`` where the bracketed numerals are placeholders for ID values that are filled in from the ``ids`` parameter to ``find()``. """ JIRA_BASE_URL = '{server}/rest/{rest_path}/{rest_api_version}/{path}' # A prioritized list of the keys in self.raw most likely to contain a human # readable name or identifier, or that offer other key information. _READABLE_IDS = ('displayName', 'key', 'name', 'filename', 'value', 'scope', 'votes', 'id', 'mimeType', 'closed') def __init__(self, resource, options, session, base_url=JIRA_BASE_URL): self._resource = resource self._options = options self._session = session self._base_url = base_url # Explicitly define as None so we know when a resource has actually # been loaded self.raw = None def __str__(self): """Return the first value we find that is likely to be human readable.""" if self.raw: for name in self._READABLE_IDS: if name in self.raw: pretty_name = text_type(self.raw[name]) # Include any child to support nested select fields. if hasattr(self, 'child'): pretty_name += ' - ' + text_type(self.child) return pretty_name # If all else fails, use repr to make sure we get something. return repr(self) def __repr__(self): """Identify the class and include any and all relevant values.""" names = [] if self.raw: for name in self._READABLE_IDS: if name in self.raw: names.append(name + '=' + repr(self.raw[name])) if not names: return '' % (self.__class__.__name__, id(self)) return '' % (self.__class__.__name__, ', '.join(names)) def __getattr__(self, item): """Allow access of attributes via names.""" try: return self[item] except Exception as e: # Make sure pickling doesn't break # *MORE INFO*: This conditional wouldn't be necessary if __getattr__ wasn't used. But # since it is in use (no worries), we need to give the pickle.dump* # methods what they expect back. They expect to either get a KeyError # exception or a tuple of args to be passed to the __new__ method upon # unpickling (i.e. pickle.load* methods). # *NOTE*: if the __new__ method were to be implemented in this class, this may have # to be removed or changed. if item == '__getnewargs__': raise KeyError(item) if hasattr(self, 'raw') and item in self.raw: return self.raw[item] else: raise AttributeError("%r object has no attribute %r (%s)" % (self.__class__, item, e)) # def __getstate__(self): # """ # Pickling the resource; using the raw dict # """ # return self.raw # # def __setstate__(self, raw_pickled): # """ # Unpickling of the resource # """ # self._parse_raw(raw_pickled) # def find(self, id, params=None): if params is None: params = {} if isinstance(id, tuple): path = self._resource.format(*id) else: path = self._resource.format(id) url = self._get_url(path) self._load(url, params=params) def _get_url(self, path): options = self._options.copy() options.update({'path': path}) return self._base_url.format(**options) def update(self, fields=None, async=None, jira=None, notify=True, **kwargs): """Update this resource on the server. Keyword arguments are marshalled into a dict before being sent. If this resource doesn't support ``PUT``, a :py:exc:`.JIRAError` will be raised; subclasses that specialize this method will only raise errors in case of user error. :param async: if true the request will be added to the queue so it can be executed later using async_run() """ if async is None: async = self._options['async'] data = {} if fields is not None: data.update(fields) data.update(kwargs) data = json.dumps(data) if not notify: querystring = "?notifyUsers=false" else: querystring = "" r = self._session.put( self.self + querystring, data=data) if 'autofix' in self._options and \ r.status_code == 400: user = None error_list = get_error_list(r) logging.error(error_list) if "The reporter specified is not a user." in error_list: if 'reporter' not in data['fields']: logging.warning( "autofix: setting reporter to '%s' and retrying the update." % self._options['autofix']) data['fields']['reporter'] = { 'name': self._options['autofix']} if "Issues must be assigned." in error_list: if 'assignee' not in data['fields']: logging.warning("autofix: setting assignee to '%s' for %s and retrying the update." % ( self._options['autofix'], self.key)) data['fields']['assignee'] = { 'name': self._options['autofix']} # for some reason the above approach fails on Jira 5.2.11 # so we need to change the assignee before if "Issue type is a sub-task but parent issue key or id not specified." in error_list: logging.warning( "autofix: trying to fix sub-task without parent by converting to it to bug") data['fields']['issuetype'] = {"name": "Bug"} if "The summary is invalid because it contains newline characters." in error_list: logging.warning("autofix: trying to fix newline in summary") data['fields'][ 'summary'] = self.fields.summary.replace("/n", "") for error in error_list: if re.search(u"^User '(.*)' was not found in the system\.", error, re.U): m = re.search( u"^User '(.*)' was not found in the system\.", error, re.U) if m: user = m.groups()[0] else: raise NotImplemented() if re.search("^User '(.*)' does not exist\.", error): m = re.search("^User '(.*)' does not exist\.", error) if m: user = m.groups()[0] else: raise NotImplemented() if user: logging.warning( "Trying to add missing orphan user '%s' in order to complete the previous failed operation." % user) jira.add_user(user, 'noreply@example.com', 10100, active=False) # if 'assignee' not in data['fields']: # logging.warning("autofix: setting assignee to '%s' and retrying the update." % self._options['autofix']) # data['fields']['assignee'] = {'name': self._options['autofix']} # EXPERIMENTAL ---> # import grequests if async: if not hasattr(self._session, '_async_jobs'): self._session._async_jobs = set() self._session._async_jobs.add(threaded_requests.put( self.self, data=json.dumps(data))) else: r = self._session.put( self.self, data=json.dumps(data)) # TODO(ssbarnea): compare loaded data in order to verify if resource was updated indeed # we had random test failures (probably) due to caching time.sleep(4) self._load(self.self) def delete(self, params=None): """Delete this resource from the server, passing the specified query parameters. If this resource doesn't support ``DELETE``, a :py:exc:`.JIRAError` will be raised; subclasses that specialize this method will only raise errors in case of user error. """ if self._options['async']: if not hasattr(self._session, '_async_jobs'): self._session._async_jobs = set() self._session._async_jobs.add( threaded_requests.delete(url=self.self, params=params)) else: return self._session.delete(url=self.self, params=params) def _load(self, url, headers=CaseInsensitiveDict(), params=None, path=None): r = self._session.get(url, headers=headers, params=params) try: j = json_loads(r) except ValueError as e: logging.error("%s:\n%s" % (e, r.text)) raise e if path: j = j[path] self._parse_raw(j) def _parse_raw(self, raw): self.raw = raw if not raw: raise NotImplementedError("We cannot instantiate empty resources: %s" % raw) dict2resource(raw, self, self._options, self._session) def _default_headers(self, user_headers): # result = dict(user_headers) # esult['accept'] = 'application/json' return CaseInsensitiveDict(self._options['headers'].items() + user_headers.items()) class Attachment(Resource): """An issue attachment.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'attachment/{0}', options, session) if raw: self._parse_raw(raw) def get(self): """Return the file content as a string.""" r = self._session.get(self.content, headers={'Accept': '*/*'}) return r.content def iter_content(self, chunk_size=1024): """Return the file content as an iterable stream.""" r = self._session.get(self.content, stream=True) return r.iter_content(chunk_size) class Component(Resource): """A project component.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'component/{0}', options, session) if raw: self._parse_raw(raw) def delete(self, moveIssuesTo=None): """Delete this component from the server. :param moveIssuesTo: the name of the component to which to move any issues this component is applied """ params = {} if moveIssuesTo is not None: params['moveIssuesTo'] = moveIssuesTo super(Component, self).delete(params) class CustomFieldOption(Resource): """An existing option for a custom issue field.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'customFieldOption/{0}', options, session) if raw: self._parse_raw(raw) class Dashboard(Resource): """A JIRA dashboard.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'dashboard/{0}', options, session) if raw: self._parse_raw(raw) class Filter(Resource): """An issue navigator filter.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'filter/{0}', options, session) if raw: self._parse_raw(raw) class Issue(Resource): """A JIRA issue.""" class _IssueFields(object): def __init__(self): self.attachment = None """ :type : list[Attachment] """ self.description = None """ :type : str """ self.project = None """ :type : Project """ self.comment = None """ :type : list[Comment] """ self.issuelinks = None """ :type : list[IssueLink] """ self.worklog = None """ :type : list[Worklog] """ def __init__(self, options, session, raw=None): Resource.__init__(self, 'issue/{0}', options, session) self.fields = None """ :type : Issue._IssueFields """ self.id = None """ :type : int """ self.key = None """ :type : str """ if raw: self._parse_raw(raw) def update(self, fields=None, update=None, async=None, jira=None, notify=True, **fieldargs): """Update this issue on the server. Each keyword argument (other than the predefined ones) is treated as a field name and the argument's value is treated as the intended value for that field -- if the fields argument is used, all other keyword arguments will be ignored. JIRA projects may contain many different issue types. Some issue screens have different requirements for fields in an issue. This information is available through the :py:meth:`.JIRA.editmeta` method. Further examples are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Edit+issues :param fields: a dict containing field names and the values to use :param update: a dict containing update operations to apply keyword arguments will generally be merged into fields, except lists, which will be merged into updates """ data = {} if fields is not None: fields_dict = fields else: fields_dict = {} data['fields'] = fields_dict if update is not None: update_dict = update else: update_dict = {} data['update'] = update_dict for field in sorted(fieldargs.keys()): value = fieldargs[field] # apply some heuristics to make certain changes easier if isinstance(value, string_types): if field == 'assignee' or field == 'reporter': fields_dict['assignee'] = {'name': value} elif field == 'comment': if 'comment' not in update_dict: update_dict['comment'] = [] update_dict['comment'].append({ 'add': {'body': value}}) else: fields_dict[field] = value elif isinstance(value, list): if field not in update_dict: update_dict[field] = [] update_dict[field].extend(value) else: fields_dict[field] = value super(Issue, self).update(async=async, jira=jira, notify=notify, fields=data) def add_field_value(self, field, value): """Add a value to a field that supports multiple values, without resetting the existing values. This should work with: labels, multiple checkbox lists, multiple select """ super(Issue, self).update(fields={"update": {field: [{"add": value}]}}) def delete(self, deleteSubtasks=False): """Delete this issue from the server. :param deleteSubtasks: if the issue has subtasks, this argument must be set to true for the call to succeed. """ super(Issue, self).delete(params={'deleteSubtasks': deleteSubtasks}) def permalink(self): """Get the URL of the issue, the browsable one not the REST one. :return: URL of the issue """ return "%s/browse/%s" % (self._options['server'], self.key) def __eq__(self, other): """Comparison method.""" return self.id == other.id class Comment(Resource): """An issue comment.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issue/{0}/comment/{1}', options, session) if raw: self._parse_raw(raw) def update(self, fields=None, async=None, jira=None, body='', visibility=None): data = {} if body: data['body'] = body if visibility: data['visibility'] = visibility super(Comment, self).update(data) class RemoteLink(Resource): """A link to a remote application from an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issue/{0}/remotelink/{1}', options, session) if raw: self._parse_raw(raw) def update(self, object, globalId=None, application=None, relationship=None): """Update a RemoteLink. 'object' is required. For definitions of the allowable fields for 'object' and the keyword arguments 'globalId', 'application' and 'relationship', see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. :param object: the link details to add (see the above link for details) :param globalId: unique ID for the link (see the above link for details) :param application: application information for the link (see the above link for details) :param relationship: relationship description for the link (see the above link for details) """ data = { 'object': object} if globalId is not None: data['globalId'] = globalId if application is not None: data['application'] = application if relationship is not None: data['relationship'] = relationship super(RemoteLink, self).update(**data) class Votes(Resource): """Vote information on an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issue/{0}/votes', options, session) if raw: self._parse_raw(raw) class Watchers(Resource): """Watcher information on an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issue/{0}/watchers', options, session) if raw: self._parse_raw(raw) def delete(self, username): """Remove the specified user from the watchers list.""" super(Watchers, self).delete(params={'username': username}) class TimeTracking(Resource): def __init__(self, options, session, raw=None): Resource.__init__(self, 'issue/{0}/worklog/{1}', options, session) self.remainingEstimate = None if raw: self._parse_raw(raw) class Worklog(Resource): """Worklog on an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issue/{0}/worklog/{1}', options, session) if raw: self._parse_raw(raw) def delete(self, adjustEstimate=None, newEstimate=None, increaseBy=None): """Delete this worklog entry from its associated issue. :param adjustEstimate: one of ``new``, ``leave``, ``manual`` or ``auto``. ``auto`` is the default and adjusts the estimate automatically. ``leave`` leaves the estimate unchanged by this deletion. :param newEstimate: combined with ``adjustEstimate=new``, set the estimate to this value :param increaseBy: combined with ``adjustEstimate=manual``, increase the remaining estimate by this amount """ params = {} if adjustEstimate is not None: params['adjustEstimate'] = adjustEstimate if newEstimate is not None: params['newEstimate'] = newEstimate if increaseBy is not None: params['increaseBy'] = increaseBy super(Worklog, self).delete(params) class IssueLink(Resource): """Link between two issues.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issueLink/{0}', options, session) if raw: self._parse_raw(raw) class IssueLinkType(Resource): """Type of link between two issues.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issueLinkType/{0}', options, session) if raw: self._parse_raw(raw) class IssueType(Resource): """Type of an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'issuetype/{0}', options, session) if raw: self._parse_raw(raw) class Priority(Resource): """Priority that can be set on an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'priority/{0}', options, session) if raw: self._parse_raw(raw) class Project(Resource): """A JIRA project.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'project/{0}', options, session) if raw: self._parse_raw(raw) class Role(Resource): """A role inside a project.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'project/{0}/role/{1}', options, session) if raw: self._parse_raw(raw) def update(self, users=None, groups=None): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. :param users: a user or users to add to the role :type users: string, list or tuple :param groups: a group or groups to add to the role :type groups: string, list or tuple """ if users is not None and isinstance(users, string_types): users = (users,) if groups is not None and isinstance(groups, string_types): groups = (groups,) data = { 'id': self.id, 'categorisedActors': { 'atlassian-user-role-actor': users, 'atlassian-group-role-actor': groups}} super(Role, self).update(**data) class Resolution(Resource): """A resolution for an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'resolution/{0}', options, session) if raw: self._parse_raw(raw) class SecurityLevel(Resource): """A security level for an issue or project.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'securitylevel/{0}', options, session) if raw: self._parse_raw(raw) class Status(Resource): """Status for an issue.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'status/{0}', options, session) if raw: self._parse_raw(raw) class User(Resource): """A JIRA user.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'user?username={0}', options, session) if raw: self._parse_raw(raw) def __hash__(self): """Hash carculation.""" return hash(str(self.name)) def __eq__(self, other): """Comparison.""" return str(self.name) == str(other.name) class Version(Resource): """A version of a project.""" def __init__(self, options, session, raw=None): Resource.__init__(self, 'version/{0}', options, session) if raw: self._parse_raw(raw) def delete(self, moveFixIssuesTo=None, moveAffectedIssuesTo=None): """Delete this project version from the server. If neither of the arguments are specified, the version is removed from all issues it is attached to. :param moveFixIssuesTo: in issues for which this version is a fix version, add this argument version to the fix version list :param moveAffectedIssuesTo: in issues for which this version is an affected version, add this argument version to the affected version list """ params = {} if moveFixIssuesTo is not None: params['moveFixIssuesTo'] = moveFixIssuesTo if moveAffectedIssuesTo is not None: params['moveAffectedIssuesTo'] = moveAffectedIssuesTo return super(Version, self).delete(params) def update(self, **args): """Update this project version from the server. It is prior used to archive versions.""" data = {} for field in args: data[field] = args[field] super(Version, self).update(**data) def __eq__(self, other): """Comparison.""" return self.id == other.id and self.name == other.name # GreenHopper class GreenHopperResource(Resource): """A generic GreenHopper resource.""" AGILE_BASE_URL = '{server}/rest/{agile_rest_path}/{agile_rest_api_version}/{path}' GREENHOPPER_REST_PATH = "greenhopper" """ Old, private API. Deprecated and will be removed from JIRA on the 1st February 2016. """ AGILE_EXPERIMENTAL_REST_PATH = "greenhopper/experimental-api" """ Experimental API available in JIRA Agile 6.7.3 - 6.7.6, basically the same as Public API """ AGILE_BASE_REST_PATH = "agile" """ Public API introduced in JIRA Agile 6.7.7. """ def __init__(self, path, options, session, raw): self.self = None Resource.__init__(self, path, options, session, self.AGILE_BASE_URL) if raw: self._parse_raw(raw) # Old GreenHopper API did not contain self - create it for backward compatibility. if not self.self: self.self = self._get_url(path.format(raw['id'])) class Sprint(GreenHopperResource): """A GreenHopper sprint.""" def __init__(self, options, session, raw=None): GreenHopperResource.__init__(self, 'sprint/{0}', options, session, raw) def find(self, id, params=None): if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH: Resource.find(self, id, params) else: # Old, private GreenHopper API had non-standard way of loading Sprint url = self._get_url('sprint/%s/edit/model' % id) self._load(url, params=params, path='sprint') class Board(GreenHopperResource): """A GreenHopper board.""" def __init__(self, options, session, raw=None): path = 'rapidview/{0}' if options['agile_rest_path'] == self.GREENHOPPER_REST_PATH else 'board/{id}' GreenHopperResource.__init__(self, path, options, session, raw) def delete(self, params=None): if self._options['agile_rest_path'] != GreenHopperResource.GREENHOPPER_REST_PATH: raise NotImplementedError('JIRA Agile Public API does not support Board removal') Resource.delete(self, params) # Utilities def dict2resource(raw, top=None, options=None, session=None): """Convert a dictionary into a Jira Resource object. Recursively walks a dict structure, transforming the properties into attributes on a new ``Resource`` object of the appropriate type (if a ``self`` link is present) or a ``PropertyHolder`` object (if no ``self`` link is present). """ if top is None: top = type(str('PropertyHolder'), (object,), raw) seqs = tuple, list, set, frozenset for i, j in iteritems(raw): if isinstance(j, dict): if 'self' in j: resource = cls_for_resource(j['self'])(options, session, j) setattr(top, i, resource) elif i == 'timetracking': setattr(top, 'timetracking', TimeTracking(options, session, j)) else: setattr( top, i, dict2resource(j, options=options, session=session)) elif isinstance(j, seqs): seq_list = [] for seq_elem in j: if isinstance(seq_elem, dict): if 'self' in seq_elem: resource = cls_for_resource(seq_elem['self'])( options, session, seq_elem) seq_list.append(resource) else: seq_list.append( dict2resource(seq_elem, options=options, session=session)) else: seq_list.append(seq_elem) setattr(top, i, seq_list) else: setattr(top, i, j) return top resource_class_map = { # JIRA specific resources r'attachment/[^/]+$': Attachment, r'component/[^/]+$': Component, r'customFieldOption/[^/]+$': CustomFieldOption, r'dashboard/[^/]+$': Dashboard, r'filter/[^/]$': Filter, r'issue/[^/]+$': Issue, r'issue/[^/]+/comment/[^/]+$': Comment, r'issue/[^/]+/votes$': Votes, r'issue/[^/]+/watchers$': Watchers, r'issue/[^/]+/worklog/[^/]+$': Worklog, r'issueLink/[^/]+$': IssueLink, r'issueLinkType/[^/]+$': IssueLinkType, r'issuetype/[^/]+$': IssueType, r'priority/[^/]+$': Priority, r'project/[^/]+$': Project, r'project/[^/]+/role/[^/]+$': Role, r'resolution/[^/]+$': Resolution, r'securitylevel/[^/]+$': SecurityLevel, r'status/[^/]+$': Status, r'user\?username.+$': User, r'version/[^/]+$': Version, # GreenHopper specific resources r'sprints/[^/]+$': Sprint, r'views/[^/]+$': Board} def cls_for_resource(resource_literal): for resource in resource_class_map: if re.search(resource, resource_literal): return resource_class_map[resource] else: # Generic Resource without specialized update/delete behavior return Resource jira-1.0.10/jira/utils/000077500000000000000000000000001304741173700146125ustar00rootroot00000000000000jira-1.0.10/jira/utils/LICENSE000066400000000000000000000030201304741173700156120ustar00rootroot00000000000000Copyright (c) Django Software Foundation and individual contributors. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of Django nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. jira-1.0.10/jira/utils/__init__.py000066400000000000000000000046711304741173700167330ustar00rootroot00000000000000# -*- coding: utf-8 -*- """JIRA utils used internally.""" from __future__ import unicode_literals import json import threading from jira.resilientsession import raise_on_error class CaseInsensitiveDict(dict): """A case-insensitive ``dict``-like object. Implements all methods and operations of ``collections.MutableMapping`` as well as dict's ``copy``. Also provides ``lower_items``. All keys are expected to be strings. The structure remembers the case of the last key to be set, and ``iter(instance)``, ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` will contain case-sensitive keys. However, querying and contains testing is case insensitive:: cid = CaseInsensitiveDict() cid['Accept'] = 'application/json' cid['aCCEPT'] == 'application/json' # True list(cid) == ['Accept'] # True For example, ``headers['content-encoding']`` will return the value of a ``'Content-Encoding'`` response header, regardless of how the header name was originally stored. If the constructor, ``.update``, or equality comparison operations are given keys that have equal ``.lower()``s, the behavior is undefined. """ def __init__(self, *args, **kw): super(CaseInsensitiveDict, self).__init__(*args, **kw) self.itemlist = {} for key, value in super(CaseInsensitiveDict, self).items(): if key != key.lower(): self[key.lower()] = value self.pop(key, None) # self.itemlist[key.lower()] = value def __setitem__(self, key, value): """Overwrite [] implementation.""" super(CaseInsensitiveDict, self).__setitem__(key.lower(), value) # def __iter__(self): # return iter(self.itemlist) # def keys(self): # return self.itemlist # def values(self): # return [self[key] for key in self] # def itervalues(self): # return (self[key] for key in self) def threaded_requests(requests): for fn, url, request_args in requests: th = threading.Thread( target=fn, args=(url,), kwargs=request_args, name=url, ) th.start() for th in threading.enumerate(): if th.name.startswith('http'): th.join() def json_loads(r): raise_on_error(r) if len(r.text): # r.status_code != 204: return json.loads(r.text) else: # json.loads() fails with empty bodies return {} jira-1.0.10/jira/utils/lru_cache.py000066400000000000000000000166651304741173700171270ustar00rootroot00000000000000try: from functools import lru_cache except ImportError: # backport of Python's 3.3 lru_cache, written by Raymond Hettinger and # licensed under MIT license, from: # # Should be removed when Django only supports Python 3.2 and above. from collections import namedtuple from functools import update_wrapper from threading import RLock _CacheInfo = namedtuple("CacheInfo", ["hits", "misses", "maxsize", "currsize"]) class _HashedSeq(list): __slots__ = 'hashvalue' def __init__(self, tup, hash=hash): self[:] = tup self.hashvalue = hash(tup) def __hash__(self): return self.hashvalue def _make_key(args, kwds, typed, kwd_mark=(object(),), fasttypes=set([int, str, frozenset, type(None)]), sorted=sorted, tuple=tuple, type=type, len=len): """Make a cache key from optionally typed positional and keyword arguments.""" key = args if kwds: sorted_items = sorted(kwds.items()) key += kwd_mark for item in sorted_items: key += item if typed: key += tuple(type(v) for v in args) if kwds: key += tuple(type(v) for k, v in sorted_items) elif len(key) == 1 and type(key[0]) in fasttypes: return key[0] return _HashedSeq(key) def lru_cache(maxsize=100, typed=False): """Least-recently-used cache decorator. If *maxsize* is set to None, the LRU features are disabled and the cache can grow without bound. If *typed* is True, arguments of different types will be cached separately. For example, f(3.0) and f(3) will be treated as distinct calls with distinct results. Arguments to the cached function must be hashable. View the cache statistics named tuple (hits, misses, maxsize, currsize) with f.cache_info(). Clear the cache and statistics with f.cache_clear(). Access the underlying function with f.__wrapped__. See: https://en.wikipedia.org/wiki/Cache_algorithms#Least_Recently_Used """ # Users should only access the lru_cache through its public API: # cache_info, cache_clear, and f.__wrapped__ # The internals of the lru_cache are encapsulated for thread safety and # to allow the implementation to change (including a possible C version). def decorating_function(user_function): cache = dict() stats = [0, 0] # make statistics updateable non-locally HITS, MISSES = 0, 1 # names for the stats fields make_key = _make_key cache_get = cache.get # bound method to lookup key or return None _len = len # localize the global len() function lock = RLock() # because linkedlist updates aren't threadsafe root = [] # root of the circular doubly linked list root[:] = [root, root, None, None] # initialize by pointing to self nonlocal_root = [root] # make updateable non-locally PREV, NEXT, KEY, RESULT = 0, 1, 2, 3 # names for the link fields if maxsize == 0: def wrapper(*args, **kwds): # no caching, just do a statistics update after a successful call result = user_function(*args, **kwds) stats[MISSES] += 1 return result elif maxsize is None: def wrapper(*args, **kwds): # simple caching without ordering or size limit key = make_key(args, kwds, typed) result = cache_get(key, root) # root used here as a unique not-found sentinel if result is not root: stats[HITS] += 1 return result result = user_function(*args, **kwds) cache[key] = result stats[MISSES] += 1 return result else: def wrapper(*args, **kwds): # size limited caching that tracks accesses by recency key = make_key(args, kwds, typed) if kwds or typed else args with lock: link = cache_get(key) if link is not None: # record recent use of the key by moving it to the front of the list root, = nonlocal_root link_prev, link_next, key, result = link link_prev[NEXT] = link_next link_next[PREV] = link_prev last = root[PREV] last[NEXT] = root[PREV] = link link[PREV] = last link[NEXT] = root stats[HITS] += 1 return result result = user_function(*args, **kwds) with lock: root, = nonlocal_root if key in cache: # getting here means that this same key was added to the # cache while the lock was released. since the link # update is already done, we need only return the # computed result and update the count of misses. pass elif _len(cache) >= maxsize: # use the old root to store the new key and result oldroot = root oldroot[KEY] = key oldroot[RESULT] = result # empty the oldest link and make it the new root root = nonlocal_root[0] = oldroot[NEXT] oldkey = root[KEY] root[KEY] = root[RESULT] = None # now update the cache dictionary for the new links del cache[oldkey] cache[key] = oldroot else: # put result in a new link at the front of the list last = root[PREV] link = [last, root, key, result] last[NEXT] = root[PREV] = cache[key] = link stats[MISSES] += 1 return result def cache_info(): """Report cache statistics.""" with lock: return _CacheInfo(stats[HITS], stats[MISSES], maxsize, len(cache)) def cache_clear(): """Clear the cache and cache statistics.""" with lock: cache.clear() root = nonlocal_root[0] root[:] = [root, root, None, None] stats[:] = [0, 0] wrapper.__wrapped__ = user_function wrapper.cache_info = cache_info wrapper.cache_clear = cache_clear return update_wrapper(wrapper, user_function) return decorating_function jira-1.0.10/jira/utils/version.py000066400000000000000000000046261304741173700166610ustar00rootroot00000000000000from __future__ import unicode_literals import datetime import os import subprocess from jira.utils.lru_cache import lru_cache def get_version(version=None): """Return a PEP 440-compliant version number from VERSION.""" version = get_complete_version(version) # Now build the two parts of the version number: # main = X.Y[.Z] # sub = .devN - for pre-alpha releases # | {a|b|rc}N - for alpha, beta, and rc releases main = get_main_version(version) sub = '' if version[3] == 'alpha' and version[4] == 0: git_changeset = get_git_changeset() if git_changeset: sub = '.dev%s' % git_changeset elif version[3] != 'final': mapping = {'alpha': 'a', 'beta': 'b', 'rc': 'rc'} sub = mapping[version[3]] + str(version[4]) return str(main + sub) def get_main_version(version=None): """Return main version (X.Y[.Z]) from VERSION.""" version = get_complete_version(version) parts = 2 if version[2] == 0 else 3 return '.'.join(str(x) for x in version[:parts]) def get_complete_version(version=None): """Return a tuple of the jira version. If version argument is non-empty, then checks for correctness of the tuple provided. """ if version is None: from jira import VERSION as version else: assert len(version) == 5 assert version[3] in ('alpha', 'beta', 'rc', 'final') return version def get_docs_version(version=None): version = get_complete_version(version) if version[3] != 'final': return 'dev' else: return '%d.%d' % version[:2] @lru_cache() def get_git_changeset(): """Return a numeric identifier of the latest git changeset. The result is the UTC timestamp of the changeset in YYYYMMDDHHMMSS format. This value isn't guaranteed to be unique, but collisions are very unlikely, so it's sufficient for generating the development version numbers. """ repo_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) git_log = subprocess.Popen( 'git log --pretty=format:%ct --quiet -1 HEAD', stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, cwd=repo_dir, universal_newlines=True, ) timestamp = git_log.communicate()[0] try: timestamp = datetime.datetime.utcfromtimestamp(int(timestamp)) except ValueError: return None return timestamp.strftime('%Y%m%d%H%M%S') jira-1.0.10/pytest.ini000066400000000000000000000015001304741173700145520ustar00rootroot00000000000000# Despise http://docs.pytest.org/en/latest/goodpractices.html# until # https://github.com/pytest-dev/pytest/issues/567 is fixed please to not attempt # to merge this into setup.cfg [pytest] norecursedirs = . .svn jira _build tmp* lib/third lib *.egg bin distutils build docs demo python_files = *.py addopts = -s -p no:xdist --ignore=setup.py --tb=long -rxX -v --color=yes --maxfail=10 --junitxml=build/results.xml --cov-report=xml --cov jira testpaths = tests # --maxfail=2 -n4 # -n4 runs up to 4 parallel procs # --maxfail=2 fail fast, dude # --durations=3 report the top 3 longest tests # these are important for distributed testing, to speedup their execution we minimize what we sync rsyncdirs = . jira demo docs rsyncignore = .hg .git pep8ignore = E501 E265 E127 E901 E128 E402 pep8maxlinelength = 1024 jira-1.0.10/release.sh000077500000000000000000000032701304741173700145060ustar00rootroot00000000000000#!/bin/bash set -e TAG=$(git describe $(git rev-list --tags --max-count=1)) VERSION=$(python setup.py --version) echo "INFO: Preparing to release version ${VERSION} based on git tag ${TAG}" exit 1 if testvercomp $TAG $VERSION '<'; then echo "." else echo >&2 "ERROR: Current version and git tag do not match, cannot make release." exit 1 fi echo "INFO: Checking that all changes are commited and pushed" git pull #git diff # Disallow unstaged changes in the working tree if ! git diff-files --check --exit-code --ignore-submodules -- >&2 then echo >&2 "ERROR: You have unstaged changes." #exit 1 fi # Disallow uncommitted changes in the index if ! git diff-index --cached --exit-code -r --ignore-submodules HEAD -- >&2 then echo >&2 "ERROR: Your index contains uncommitted changes." #exit 1 fi # Use the gitchangelog tool to re-generate automated changelog gitchangelog > CHANGELOG if [ -z ${CI+x} ]; then echo "WARN: Please don't run this as a user. This generates a new release for PyPI. Press ^C to exit or Enter to continue." else echo "INFO: Automatic deployment" fi git add CHANGELOG git commit -m "Auto-generating release notes." git tag -fa ${VERSION} -m "Version ${VERSION}" git tag -fa -a RELEASE -m "Current RELEASE" NEW_VERSION="${VERSION%.*}.$((${VERSION##*.}+1))" set -ex sed -i.bak "s/${VERSION}/${NEW_VERSION}/" setup.py git commit -m "Auto-increasing the version number after a release." # disables because this is done only by Travis CI from now, which calls this script after that. #python setup.py register sdist bdist_wheel build_sphinx upload --sign git push --force origin --tags echo "INFO: done." jira-1.0.10/requirements-all.txt000066400000000000000000000001041304741173700165520ustar00rootroot00000000000000-r requirements.txt -r requirements-opt.txt -r requirements-dev.txt jira-1.0.10/requirements-dev.txt000066400000000000000000000010121304741173700165570ustar00rootroot00000000000000# The order of packages is significant, because pip processes them in the order # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. py >= 1.4 hacking>=0.13 MarkupSafe>=0.23 coveralls>=1.1 docutils>=0.12 oauthlib pytest-cache pytest-cov pytest-instafail pytest-xdist>=1.14 pytest>=2.9.1 requires.io sphinx>=1.3.5 sphinx_rtd_theme tox>=2.3.1 tox-pyenv wheel>=0.24.0 xmlrunner>=1.7.7 yanc>=0.3.3 unittest2; python_version < '3.1' flaky tenacity jira-1.0.10/requirements-opt.txt000066400000000000000000000004261304741173700166130ustar00rootroot00000000000000# The order of packages is significant, because pip processes them in the order # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. ipython>=4.0.0 PyJWT requests_jwt requests_kerberos filemagic>=1.6 jira-1.0.10/requirements.txt000066400000000000000000000006031304741173700160100ustar00rootroot00000000000000# The order of packages is significant, because pip processes them in the order # of appearance. Changing the order has an impact on the overall integration # process, which may cause wedges in the gate later. pbr ordereddict; python_version < '3.1' argparse; python_version < '3.2' requests-oauthlib>=0.6.1 requests>=2.10.0 requests_toolbelt setuptools>=20.10.1 six>=1.10.0 defusedxml jira-1.0.10/setup.cfg000066400000000000000000000034351304741173700143530ustar00rootroot00000000000000[metadata] name = jira author = Ben Speakmon author-email = ben.speakmon@gmail.com maintainer = Sorin Sbarnea maintainer-email = sorin.sbarnea@gmail.com summary = Python library for interacting with JIRA via REST APIs. description-file = README.rst home-page = https://github.com/pycontribs/jira license = BSD classifier = Development Status :: 5 - Production/Stable Environment :: Other Environment Intended Audience :: Developers Intended Audience :: Information Technology License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.4 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Topic :: Software Development :: Libraries :: Python Modules Topic :: Internet :: WWW/HTTP keywords = api atlassian jira rest web rest [files] packages = jira [entry_points] console_scripts = jirashell = jira.jirashell:main [egg_info] egg_base = . [aliases] test=pytest [bdist_wheel] universal = 1 [build_sphinx] source-dir = docs build-dir = docs/build all_files = 1 [upload_sphinx] upload-dir = docs/build/html [flake8] max-line-length=160 exclude=build,.eggs,.tox statistics=yes ignore = D100,D101,D102,D103,F405,B001,B002 # TODO(ssbarnea): remove ignored flake8 rules one by one by fixing them. [pep8] exclude=build,lib,.tox,third,*.egg,docs,packages,.eggs ;filename= ;select ignore=E501,E265,E402 max-line-length=160 statistics=yes ;format ;quiet ;show-pep8 ;show-source ;verbose=1 ;PEP8_OPTS="--filename=*.py --exclude=lib --ignore=E501 scripts" ;pep8 $PEP8_OPTS --show-source --repeat ;pep8 --statistics -qq $PEP8_OPTS [pbr] warnerrors = truejira-1.0.10/setup.py000066400000000000000000000017261304741173700142450ustar00rootroot00000000000000#!/usr/bin/env python import setuptools from setuptools.command.test import test as TestCommand import sys # In python < 2.7.4, a lazy loading of package `pbr` will break # setuptools if some other modules registered functions in `atexit`. # solution from: http://bugs.python.org/issue15881#msg170215 try: import multiprocessing # noqa except ImportError: pass class PyTest(TestCommand): def initialize_options(self): TestCommand.initialize_options(self) self.pytest_args = [] def finalize_options(self): TestCommand.finalize_options(self) self.test_args = [] self.test_suite = True def run_tests(self): # import here, cause outside the eggs aren't loaded import pytest errno = pytest.main(self.pytest_args) sys.exit(errno) setuptools.setup( setup_requires=['pbr>=1.9', 'setuptools>=17.1', 'pytest-runner'], pbr=True, cmdclass={'test': PyTest}, test_suite='tests') jira-1.0.10/test.local000077500000000000000000000011051304741173700145200ustar00rootroot00000000000000#!/bin/bash # Settings for using the Vagrant VM from atlassian # (see https://developer.atlassian.com/static/connect/docs/latest/developing/developing-locally.html) # a user "jira_user" with password "jira" needs to be created manually export CI_JIRA_URL="http://localhost:2990/jira" export CI_JIRA_ADMIN="admin" export CI_JIRA_ADMIN_PASSWORD="admin" export CI_JIRA_USER=jira_user export CI_JIRA_USER_PASSWORD=jira export CI_JIRA_ISSUE=Task if [ "$1" = "--tox" ] ; then shift exec tox "$@" else exec python -m pytest --cov-report xml --cov jira --pyargs jira "$@" fi jira-1.0.10/tests/000077500000000000000000000000001304741173700136675ustar00rootroot00000000000000jira-1.0.10/tests/icon.png000066400000000000000000000315031304741173700153270ustar00rootroot00000000000000PNG  IHDR>asBIT|d pHYs  ~tEXtSoftwareAdobe FireworksONtEXtCreation Time04/17/08DZHtEXtXML:com.adobe.xmp Adobe Fireworks CS3 2008-04-16T16:24:06Z 2008-10-31T04:27:57Z image/png T.IDATx} $Wyuͼ7o4Z@Xc "ɱYNpb[x`;$ǀ;'$`6D`#߼}齻UVw[FoF=Rߙ:U߿^9^{6=Z@k=Z@k=Z@Jھ_Nɯq8|%~#}I=y@/";؎ l۱ŞYm҃~2ErEvLG@@%սeYߖ{Tz|?7ٟɸ?1 FgkB{qXm +xe=OS%jȽiMFk y]k#|Xė\F|[m^yr&>茶8`]kܐ+Q=]u lD"(`s Gh_H@k Zcǿr7o|B/ЎHb!BǤF:5cjQl:tW(9IN-A4 КD,O{Acbv쎉&"׿ݺ#@a-HA6a6̙?Zx=lT-uw=\5*rp~XB tԁgKNx/?Eh?@\Si}>erPXj1{@!Ix% Bt:1 6o;]}anqn\j z*Uno1WP96}GTnďA|_^eoXFOlvAϩgH0<^29\PAͳ,,,^o \J~5«-]J_&Դm_ N}00ddޕl޿}N 2#7K yA*F6n=1v=\`z.w]_s"r imW2Uܲ+&A#m U-J\ݒQ~u_L`Y ݗ8*@x;0y Aׇ$,#Krpt4pJ @ !J`"xgCAO\@A숬ޑyr,㢼$c"΍G`|dЍUn7R`G8s_i(ztN׭UW95;Q sO\8Iܝ`b#̸?s\;.+elߏI]ppk1+R@G+YfSOں~UkRBMxv=kC@|E`ZZTkb.ŭaUn6L qиrYB=[,.Cq(@/.O@\`&80t-ΟwCD2!jmf)$鸸RJ2K`խG2zLf0jC4[wb[, LSǓЗT+0;3 ZMh4 X'~"c<" %QY-U[.!Iн&#f7 *U@^jT\|-]٧n}Wdj/p"s*֪5AFP D(Dā=d<8N -bm^ˡu%DkoS[o\o~5rh~87 '0_O/ \2XUṃ`8T FS\4 c7u_xA zX68p1ǣhDcbR"rqM H:РtLbx d{ @SíXpHxM4'N-"㸄Ԕc4P~xFW!4 u6ipN=:o3 sh$|~?uc(h-s7A ue(XZRD7ϣ%+Ds<q=[y,Λuz˻tmbZpx8{iX=$ EH8sK] @v+\ rZ_B^` ?>7vK;PJw7r\ӟHєkA_e8@=Dvr.gEavveM~#y/ؔ6 {xy{&ËO#Fq~~>c A[~^ntܘB '?`"[x}we(×|wfff֧mN! |wNJwzv'w^+!S7vo(P7\U -C⧐+x20'yJJm Ik9~H܎_g]) uK @U+{\_[WPsK-_s 3sHOC?L,~]gK=[Nkw޹Ip.({:~.c`lr#IƢE %3bjFaoTG{{xPmc/[ @)^СCp4_+u?rv!bĸ-}(6|MZ5H[Oef8@E~,C=g"A-;'~YkFbk+[0q׋+ ~T1t,8 Ss $B㍯=GSټL C$>"dГb{*a``"Y>߅}Iu..{WYYh٢J@ʶYF@ #j!k.**~{]/=#jd/t|O#Wn8z:Nt_TC!&J֩zi"7kG<4 3eVJ5~A9Z*cѓ]ܳZ28q~;ͻw}o"1`6Y?w6_|>?pu$ MzBI[hK4hN<)7oNϮ [ | uv8^@dJ) ȏŨePX>5sz^%8h. 6r#1\/`]kIb'<:sJ3e'?K8SÐ-U 4(胳U;ݛ<0""Tܬ(FICj` ZDG) f Hw@eR>x柝_?l?w*ZЙw;ȉ~ؽT6%|5Nd& &y ~ ruB<$%*{ = ܈ .́5S POC;4 6DP#b*JrչJ1?q _b݀1l/,QkIp b4bL %w/\m#P,`\t4-G>7ߚj#Pm@{g3mkgvf,vǽo,PY)t>ukCNrgc-3v{* dZB:7M87?vqb&2 (¤Aa 7~cyz%i;M=}w]_ Z}R 4UDܹ/")XEk#CԩS]Q,mjj ~81Xl,B$ !Dvuю0ϔF\By\ńnE2W6}oa; (x_Z2 Ky %_s8sfa|x894c7린ywѬ@Z>spѣGazq')+H"> R&Y(zJYSpgB+.#;wW"yq0L7S\^zDr,lұ|ͩ"LLL-c?}13H`e2hb˓䑓GtL[[:Nn j(%8䂡5LT@۔Vزc8{v p's"H&1N MٳW}G;5}F'FMg*@wUI) J 2Z.HQHuɽ%  ?+ t;$#cZD"}0}f=O!HW׫uJҐe ݗJNԫBwj}>fmM^ӛG|ZU _Ÿ~7Χ(*-w?3YT0o[+ 9ooVȅFzD,Xpyaͩ8::˵y$jG$@D' ^|F 8$!,,.Xdp }~8hXWXYX61E},X$C:Jɤ3 LݾN_,?%ҹcKYm(\@\5c‚q9E٩ټ? =QExŹEyL[L!Kܺ꽰=>XR|]N̜? O~wFt3ykɚ;1cmHcp,.DN'?fMҍ06ORִ߱nQ|}?H6@>/" @*:W]WƆ:>Qۑ8?;SgiWoG][?d |橃d" rUl>~F_"8=DEp2&-&Z"XI{vאu` 5"GIؿ~[ {u(uҽma<,-ARun6A@ϝcB~A3+I,.D3馤h}}"%L¥_@&U+-?T fmA$[w H?u\pEH:%NAPJ+%A S-ȵrMO(+w[w_eK=qf@aphU:E*ojT~A%L `A2%ׇz+TP%{+EǨIS)aԴH̤3ia fsY&h;)2@:~fvFa۫&SA2l 0Y,/щc'Ff?Q| T*Es@LgJvnjf*y*vCCȦ'pv7'.V #7+f[P,)#C/ PnO-Ђ Pk}'b\j&7jZDH!YksP5O!ҩXSw:Y!ԖN~;@[n{,-ͳ5(T*;)jUtnٹd\ꡥH n۵M\+r<4h:#V+DLS-7WL+-w"KwD#R'E2 N$ 0z^,A=CLxk88( "D$lU7][F&+nd\<]e -F)($04氵*R/؅@XG0cYEK:D+]+G'.T6HN9H%DzHNխkj v3 2U+W`lb FHq%ys9Qd"'ukQ Ge]=-uzغr " = R!`V!A}2u$$_sdy-% VJn$O:1U e"E*^ .-A{$X~VQ="e4ZkXN:VnpdUJ BfTEDaWr(bZ8)bTOQ8uIVB߭KSR߷*ZɴwzAhˣ)i7yWe@E+dyMPT5ȲY'0OMBFpQ׫Pp$WZEHt!d81,uHA,✷Ã-] \,!V)ЌAW+ Wch4&e~A%8@ ð͟|Fa[tލ1?~!@H9۾>tnqea3cyk:Yݙ K"5w*:XZ$h.Y.W} /)k;|z PKz!HKUAѳFF$s KjZ2#:+U"7KQ4~a ,^cq{}bӣSU3|X\J4.g7_ [h$;p41OMF0O'LFM` }&Z@7|T=E;p3|_%D@o.p67_jkoz3X,Xf] B8L:8f0g}ߩf_E\@2DgH8>RnT hTha<)| yլ6weM/ ,Щ-yGrĜ;4YB~xb"'V]1MXz=#IАynEQZcFZR/8*h¤Pgl]TcŅw]u?Xs).BW,064XcEjfML@YW ȸ_!ϟᾑXH5ބ?oz{w'#HϋrE8(%:ץ1xg3 6@@M#k5y>IUPfiiqFDIЀHyv=;?8xcꅅ{yZf6rkF77+n 7e)]E Z7hDubI3*o- f*Q#ΐּy^P|<=+sL+ܩ __j<אknx }`J]PGILO.^R+OՉBC"zBZ?7# &6GGC+Q]q98RSfus">x G/i6 iP>hjDP"Y//ZBuУΒck:ב=gaжnVx8R6FdplA1͜rM A AeR+*åFmHc$<=wyAҹ% 'cXvhы p.܋myQ$k3Ap>XUGu4LG Q ٔ G!#t ~!Z4' ]b3?1w|"c 9Q YTxXR#hnu ;ɢw/90y }r^4zBuQld >-Bʦxunr-F~F@1w;(:ǂкGt&\s=e5 zs0 `9$w8ǧ"Pm!m-{Q < !1^vq 2 pgD@hbL?z _C@ G'`_τ ϹػfpiHSq&Sb4%Mshz(\'PPA'qWN?+>$*{69* _nU˭ļ6蠺6F HE0 ]Bd;[o~ e^G͛gquh*KiE8x6RNr#N+K#R(켈p eЦ@oHVO:$EF K%<bC7Vy_練F#r/Hkd2h])Xl]y7Z2o·MLk66A`Zcn^"x7#XkwvLNdsRaYN *:uʅ5{`] '|  k@~N Q߀hͷ3Dٺ~G3^|z^{\ʃ~D1IENDB`jira-1.0.10/tests/id_rsa000066400000000000000000000062531304741173700150610ustar00rootroot00000000000000-----BEGIN RSA PRIVATE KEY----- MIIJKQIBAAKCAgEA6qMJuX275nAGVEzurwUQsB3gXMmVKTwieks2sue3DkC2rEpA J5OOmL/GWaR8uuiBqWDtaACLZIsLoTDhvk4RhnyIW9RJpNqUOMtoZqSes01lF7DU bU3MW/hJK3uPYOudv19MDIkfbXr+efvqgT4yFbZ5cyzVRSxU+mXfSK8haO/HwU1o Jrkn+rbZnUhqM/V/nIakWFvklUdM7HbtF6K01rhnWZ8p7wjtsclTLJGqofkVGoR8 5WeXI3ubPFsiGB/wrS74Z36POazNsaQBKHeruozjz7EwoHjojxarwd9rPfJAVIib puA7NI061NeJ8n0dEvclqPJS7iCSOFZjcveKMfPT4j0+NMDXVptDQsNlQjZRBkyH RwkO1AgRQVAyRTArcsbhUK1mXOGHMAGeL7roxWcuw09QZr27LsmkgMQLwxtWTX0R jKJs4/+IWyuunNhULtiUuwlI5mzoAizkuiBxCaZMx4E0E+MFbWlgL6RBdZ8DE3nr JqtXUgIcH1xxU9SYD5a3ar76oTsRDqwJWnIrvKLpXyIyYa51W6tr1xi052AKfxZg thUDw2zCAb+2Ql8T4TFHOwZcg1Y8+gUTDNd06ipld72FOPrx5W8uEQwSd84Vbmu4 MVOOpuSvA+VpXJSkt1Q994uG9ssu5JEedlUYANywJJhar06GOfT7E1o3/PsCAwEA AQKCAgAjU3akWbzPffBGAuswqJeRnH3qGmN9uNMMDITovKA/4hheqjMsgjfG4aCw YGZzEYxr/u7faK2T7qdKlnx2VXSoBdnV0Ylg65PDVUSbp49JOY7N2U6yQjNRaXlC tbCpi+/NH0Do5kA4EHt9zCLLYJzTzgxM/eQCLSGCLZJHdC6YiPlPLiNPKTNOuPbc ikmLFxwmadMWhodMvlZjh6g8lb+aUFsnECKVHYgD62a9YBULm9/EhUv0kfscWYDO vn3Mmgp3WIoHsvNHYK/7XdDa0eGmDY5C9891aZ7B5EzpvIR96BotX//nSP1A9T51 Sxo2ywV0lIcz/3/i4D6DguYoKgLBJrYdFNsUP3X5PJhwnRQNLRu9z+UGJAZFLROg hfBIskyEAXdmojB+UrHuVuhWb5TNbfUAYmI4KhPq3zdjb4JUOlK9JzGiBxgvD+MF rDV92FH0eU6DfWb6TEtavUV0xUJo2VMrq26foj2/x+PvBGTeRSXCNa3xCaMgP75F yXvM7MsMqv1ZetKJlqn0TByimD7cmbKiKmcwyYlQdopu81rFI6vAGwGarVvD3KMW G5G9PcG/NeUP46mWWjvYyfOtEToog6r+2+rwen9i0oYcGfxAr2BNzmGKOqtTYcrK l01ACUGQ8BfCYgASokyl9jUKt6hWb+zasrUpBM4rBOOyvFcDaQKCAQEA/lDCkcay 085rbc45EMpEBBrvcf7QwPOCtqrQ93aXtWBe9YLagpVutlNspgO2A3lUIyxizUXN IX8swJ+Zt2/ONJgP729Y4b+51aFwZOHRci8E70ouhwctAoge70Bf6K5y8DR9H9kP 431E7GoHv36JLKmyRmylwMILNxFdDaoFdzicqlVmgeNpJRnzA/dn8oY/E1op9DRQ sLvNX0i6cTq7IdHQrtx+1lYZrzl08LXEs5a5b9P36tMFpCfbuosIZRIaqeykbxZv jknxiZJrTDjcnzWWwb9EANamk3q1dyBMTLPJaV+xkBILJhzVyr/laR8ftUTGkarh qPAk/TA6z7GRHwKCAQEA7DDovrAd8g/7yyjiOMath1151/FbZry3ewwOfjqO+qUj SBVEmMtC0pfbuXSCR9A+Q46Z6hdnFmhZcNGQ91chlnhOXg5PPEyX1OdenYRhZHAf 1ns6Iqm4SvbPK0SP59GVCCH32/2CBFJfmtTsL3x3LK+V/O2BAU6G/BCOlF+5CRP2 dr9a0BhS9azunToQ4iXliLrTLyaWbwY5Oi+UKaahPm6MjWdfgza5ol7I+/FYCWJo InpMgb+PZRKF8mjoot8YGcSmMK3Ss5FEt9e4Uth5IHmj3B1+ZwuYTOSX2lnKFiMM 2lK6e1qlyoB/xPfgogBZZw4aww02cJoBEQtUPwgMpQKCAQEAmUl4XYGUnFIZMrBQ eSxRXuAVX3KlxQeBzDSdi+sxeiPCWN0sc/U6LC+Ql2g5N1LUQfco/m2KPRx4jwok DwsXEWBuinVk730ut/N82XG7WsW4hbsC3GSY3qPJcZAtvwQXR2171cxx5T7GYnFu hh/w8ri+OfCW396V//U5T1khvkCjPZAIH1ZBNBm1/rgLMYV1U2bPTuCRmlU4bqxZ pJIv5SygSiWhVfPDu3g4YjZNf6njz+HF1wamqdFUgdX3k2QcKjv2yPaO+wbazX8x qVnEsToNym5MwOygrtgRtOIE216qkhcZ4areiXRr8K9Fydz2sb3oqjiDl95XjTya 1kFDJQKCAQA0nDhbsVMaRiEqAbNSPj8M9e9cAHEBk2uzRt47k8OhZQNU3RfoiO4b hqP0zVTvth0IY005bXkS7q9th+Col4ntwGKEZN+VaOIxFFBo+cHP44HT/qLWccOR PySqWJ1NX8u4ggh5wiAh4k9VZ7QsZ6cMFxhrvGON7PX4U5/OwPuwX/f4P2t1CtX/ z0NfVj3IgfR83lCIIipEFLjOkyaHmIw2Id3A6ZPG4Hu9BSvzorCfdoIHnAJKrGa+ dr/LXT7keJkftEPod++E/Ai4gp6WJY3lg/LR5ufvABAuoISKqJFxGOGWB/Nt4qUn VDQhpa0tqLJBWEzxwZGsx0ERkNp1J8/ZAoIBAQDJr9KgC3CQodqSTR7/4ExijqS1 beD+w7pMKcIegIp8vBKYzylriNRiyEBH15fzf+YTGmeoIvdjxbGWxq7dhl5mQj/t Xhd3XEj2z4ldvbnnFg72JW15a6nLAQzFGuhklfbxKRNnBzAPdZp7QWxl5baf5tAD xRFv0PDwy9AQ6U4EoZmn4T5cD68e7w4+NDrZ6uVpktYP/ojAk5bVRUIAveYGWz7t J8/7qLJNrREKmoLDxoD+a8exRVt5sSOTg/n5kCrjUk0lVU+07z2utGlT68KOQSgy 6xpYN+XW70BENAcREEVagZjkCDUbap9WMyPp6KwYzDqBr9tdpV8pXirEpLAq -----END RSA PRIVATE KEY----- jira-1.0.10/tests/id_rsa.pub000066400000000000000000000013541304741173700156430ustar00rootroot00000000000000ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDqowm5fbvmcAZUTO6vBRCwHeBcyZUpPCJ6Szay57cOQLasSkAnk46Yv8ZZpHy66IGpYO1oAItkiwuhMOG+ThGGfIhb1Emk2pQ4y2hmpJ6zTWUXsNRtTcxb+Ekre49g652/X0wMiR9tev55++qBPjIVtnlzLNVFLFT6Zd9IryFo78fBTWgmuSf6ttmdSGoz9X+chqRYW+SVR0zsdu0XorTWuGdZnynvCO2xyVMskaqh+RUahHzlZ5cje5s8WyIYH/CtLvhnfo85rM2xpAEod6u6jOPPsTCgeOiPFqvB32s98kBUiJum4Ds0jTrU14nyfR0S9yWo8lLuIJI4VmNy94ox89PiPT40wNdWm0NCw2VCNlEGTIdHCQ7UCBFBUDJFMCtyxuFQrWZc4YcwAZ4vuujFZy7DT1BmvbsuyaSAxAvDG1ZNfRGMomzj/4hbK66c2FQu2JS7CUjmbOgCLOS6IHEJpkzHgTQT4wVtaWAvpEF1nwMTeesmq1dSAhwfXHFT1JgPlrdqvvqhOxEOrAlaciu8oulfIjJhrnVbq2vXGLTnYAp/FmC2FQPDbMIBv7ZCXxPhMUc7BlyDVjz6BRMM13TqKmV3vYU4+vHlby4RDBJ3zhVua7gxU46m5K8D5WlclKS3VD33i4b2yy7kkR52VRgA3LAkmFqvToY59PsTWjf8+w== pycontribs@example.com jira-1.0.10/tests/start-jira.sh000077500000000000000000000006421304741173700163100ustar00rootroot00000000000000#!/bin/bash DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" JIRA_URL=http://127.0.0.1:2990/jira/secure/Dashboard.jspa cd "$DIR" rm jira.log atlas-run-standalone --product jira --http-port 2990 -B -nsu -o --threads 2.0C jira.log 2>&1 & printf "Waiting for JIRA to start respinding on $JIRA_URL " until $(curl --output /dev/null --silent --head --fail $JIRA_URL); do printf '.' sleep 5 done jira-1.0.10/tests/stop-jira.sh000077500000000000000000000002571304741173700161420ustar00rootroot00000000000000#!/bin/bash set -ex kill $(ps -o pid,command|grep atlassian-plugin-sdk|grep java|awk '{print $1}') #ps -o pid,command|grep atlassian-plugin-sdk|grep java|awk '{kill -9 $1;}' jira-1.0.10/tests/test_client.py000066400000000000000000000103661304741173700165640ustar00rootroot00000000000000from flaky import flaky import getpass import json import pytest from tests import get_unique_project_name from tests import JiraTestManager import time from jira import Role, Issue, JIRA, JIRAError, Project # noqa import jira.client @pytest.fixture(scope='module') def test_manager(): return JiraTestManager() @pytest.fixture() def cl_admin(test_manager): return test_manager.jira_admin @pytest.fixture() def cl_normal(test_manager): return test_manager.jira_normal @pytest.fixture(scope='function') def slug(request, cl_admin): def remove_by_slug(): try: cl_admin.delete_project(slug) except ValueError: # Some tests have project already removed, so we stay silent pass slug = get_unique_project_name() project_name = ( "Test user=%s key=%s A" % (getpass.getuser(), slug) ) try: proj = cl_admin.project(slug) except JIRAError: proj = cl_admin.create_project(slug, project_name) assert proj request.addfinalizer(remove_by_slug) return slug @flaky @pytest.mark.xfail(reason='fails often but only with Travis') def test_delete_project(cl_admin, cl_normal, slug): time.sleep(6) # with <=5s was failing often with pytest.raises(JIRAError) as ex: assert cl_normal.delete_project(slug) assert 'Not enough permissions to delete project' in str(ex.value) try: assert cl_admin.delete_project(slug) except Exception as e: e.message += " slug=%s" % slug raise def test_delete_inexistant_project(cl_admin): slug = 'abogus123' with pytest.raises(ValueError) as ex: assert cl_admin.delete_project(slug) assert ( 'Parameter pid="%s" is not a Project, projectID or slug' % slug in str(ex.value) ) def test_template_list(): text = ( r'{"projectTemplatesGroupedByType": [' ' { "projectTemplates": [ { "projectTemplateModuleCompleteKey": ' '"com.pyxis.greenhopper.jira:gh-scrum-template", ' '"name": "Scrum software development"}, ' '{ "projectTemplateModuleCompleteKey": ' '"com.pyxis.greenhopper.jira:gh-kanban-template", ' '"name": "Kanban software development"}, ' '{ "projectTemplateModuleCompleteKey": ' '"com.pyxis.greenhopper.jira:' 'basic-software-development-template",' ' "name": "Basic software development"} ],' ' "applicationInfo": { ' '"applicationName": "JIRA Software"} }, ' '{ "projectTypeBean": { ' '"projectTypeKey": "service_desk", ' '"projectTypeDisplayKey": "Service Desk"}, ' '"projectTemplates": [ { ' '"projectTemplateModuleCompleteKey": ' '"com.atlassian.servicedesk:classic-service-desk-project", ' '"name": "Basic Service Desk"},' ' { "projectTemplateModuleCompleteKey": ' '"com.atlassian.servicedesk:itil-service-desk-project",' ' "name": "IT Service Desk"} ], ' '"applicationInfo": { ' '"applicationName": "JIRA Service Desk"} }, ' '{ "projectTypeBean": { ' '"projectTypeKey": "business", ' '"projectTypeDisplayKey": "Business"}, ' '"projectTemplates": [ { ' '"projectTemplateModuleCompleteKey": ' '"com.atlassian.jira-core-project-templates:jira-core-task-management", ' '"name": "Task management"}, {' ' "projectTemplateModuleCompleteKey": ' '"com.atlassian.jira-core-project-templates:jira-core-project-management", ' '"name": "Project management"}, { ' '"projectTemplateModuleCompleteKey": ' '"com.atlassian.jira-core-project-templates:jira-core-process-management", ' '"name": "Process management"} ], ' '"applicationInfo": { "applicationName": "JIRA Core"} }],' ' "maxNameLength": 80, "minNameLength": 2, "maxKeyLength": 10 }' ) # noqa j = json.loads(text) template_list = jira.client._get_template_list(j) assert [t['name'] for t in template_list] == ["Scrum software development", "Kanban software development", "Basic software development", "Basic Service Desk", "IT Service Desk", "Task management", "Project management", "Process management"] jira-1.0.10/tests/tests.py000077500000000000000000002577251304741173700154300ustar00rootroot00000000000000#!/usr/bin/env python from __future__ import print_function import getpass import hashlib import inspect import logging import os import pickle import platform import random import re import string import sys from time import sleep import traceback from flaky import flaky import py import pytest import requests from six import integer_types from tenacity import retry from tenacity import stop_after_attempt # _non_parallel is used to prevent some tests from failing due to concurrency # issues because detox, Travis or Jenkins can run test in parallel for multiple # python versions. # The current workaround is to run these problematic tests only on py27 _non_parallel = True if platform.python_version() < '3': _non_parallel = False try: import unittest2 as unittest except ImportError: import pip if hasattr(sys, 'real_prefix'): pip.main(['install', '--upgrade', 'unittest2']) else: pip.main(['install', '--upgrade', '--user', 'unittest2']) import unittest2 as unittest else: import unittest cmd_folder = os.path.abspath(os.path.join(os.path.split(inspect.getfile( inspect.currentframe()))[0], "..")) if cmd_folder not in sys.path: sys.path.insert(0, cmd_folder) import jira # noqa from jira import Role, Issue, JIRA, JIRAError, Project # noqa from jira.resources import Resource, cls_for_resource # noqa TEST_ROOT = os.path.dirname(__file__) TEST_ICON_PATH = os.path.join(TEST_ROOT, 'icon.png') TEST_ATTACH_PATH = os.path.join(TEST_ROOT, 'tests.py') OAUTH = False CONSUMER_KEY = 'oauth-consumer' KEY_CERT_FILE = '/home/bspeakmon/src/atlassian-oauth-examples/rsa.pem' KEY_CERT_DATA = None try: with open(KEY_CERT_FILE, 'r') as cert: KEY_CERT_DATA = cert.read() OAUTH = True except Exception: pass if 'CI_JIRA_URL' in os.environ: not_on_custom_jira_instance = pytest.mark.skipif(True, reason="Not applicable for custom JIRA instance") logging.info('Picked up custom JIRA engine.') else: def noop(arg): return arg not_on_custom_jira_instance = noop def rndstr(): return ''.join(random.sample(string.ascii_lowercase, 6)) def rndpassword(): # generates a password of lengh 14 s = ''.join(random.sample(string.ascii_uppercase, 5)) + \ ''.join(random.sample(string.ascii_lowercase, 5)) + \ ''.join(random.sample(string.digits, 2)) + \ ''.join(random.sample('~`!@#$%^&*()_+-=[]\\{}|;\':<>?,./', 2)) return ''.join(random.sample(s, len(s))) def hashify(some_string, max_len=8): return hashlib.md5(some_string.encode('utf-8')).hexdigest()[:8].upper() def get_unique_project_name(): jid = "" user = re.sub("[^A-Z_]", "", getpass.getuser().upper()) if user == 'TRAVIS' and 'TRAVIS_JOB_NUMBER' in os.environ: # please note that user underline (_) is not suppored by # jira even if is documented as supported. jid = 'T' + hashify(user + os.environ['TRAVIS_JOB_NUMBER']) else: identifier = user + \ chr(ord('A') + sys.version_info[0]) + \ chr(ord('A') + sys.version_info[1]) jid = 'Z' + hashify(identifier) return jid class Singleton(type): def __init__(cls, name, bases, dict): super(Singleton, cls).__init__(name, bases, dict) cls.instance = None def __call__(cls, *args, **kw): if cls.instance is None: cls.instance = super(Singleton, cls).__call__(*args, **kw) return cls.instance class JiraTestManager(object): """Used to instantiate and populate the JIRA instance with data used by the unit tests. Attributes: CI_JIRA_ADMIN (str): Admin user account name. CI_JIRA_USER (str): Limited user account name. max_retries (int): number of retries to perform for recoverable HTTP errors. """ # __metaclass__ = Singleton # __instance = None # # Singleton implementation # def __new__(cls, *args, **kwargs): # if not cls.__instance: # cls.__instance = super(JiraTestManager, cls).__new__( # cls, *args, **kwargs) # return cls.__instance # Implementing some kind of Singleton, to prevent test initialization # http://stackoverflow.com/questions/31875/is-there-a-simple-elegant-way-to-define-singletons-in-python/33201#33201 __shared_state = {} @retry(stop=stop_after_attempt(2)) def __init__(self): self.__dict__ = self.__shared_state if not self.__dict__: self.initialized = 0 try: if 'CI_JIRA_URL' in os.environ: self.CI_JIRA_URL = os.environ['CI_JIRA_URL'] self.max_retries = 5 else: self.CI_JIRA_URL = "https://pycontribs.atlassian.net" self.max_retries = 5 if 'CI_JIRA_ADMIN' in os.environ: self.CI_JIRA_ADMIN = os.environ['CI_JIRA_ADMIN'] else: self.CI_JIRA_ADMIN = 'ci-admin' if 'CI_JIRA_ADMIN_PASSWORD' in os.environ: self.CI_JIRA_ADMIN_PASSWORD = os.environ[ 'CI_JIRA_ADMIN_PASSWORD'] else: self.CI_JIRA_ADMIN_PASSWORD = 'sd4s3dgec5fhg4tfsds3434' if 'CI_JIRA_USER' in os.environ: self.CI_JIRA_USER = os.environ['CI_JIRA_USER'] else: self.CI_JIRA_USER = 'ci-user' if 'CI_JIRA_USER_PASSWORD' in os.environ: self.CI_JIRA_USER_PASSWORD = os.environ[ 'CI_JIRA_USER_PASSWORD'] else: self.CI_JIRA_USER_PASSWORD = 'sd4s3dgec5fhg4tfsds3434' self.CI_JIRA_ISSUE = os.environ.get('CI_JIRA_ISSUE', 'Bug') if OAUTH: self.jira_admin = JIRA(oauth={ 'access_token': 'hTxcwsbUQiFuFALf7KZHDaeAJIo3tLUK', 'access_token_secret': 'aNCLQFP3ORNU6WY7HQISbqbhf0UudDAf', 'consumer_key': CONSUMER_KEY, 'key_cert': KEY_CERT_DATA}) else: if self.CI_JIRA_ADMIN: self.jira_admin = JIRA(self.CI_JIRA_URL, basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), logging=False, validate=True, max_retries=self.max_retries) else: self.jira_admin = JIRA(self.CI_JIRA_URL, validate=True, logging=False, max_retries=self.max_retries) if self.jira_admin.current_user() != self.CI_JIRA_ADMIN: # self.jira_admin. self.initialized = 1 sys.exit(3) if OAUTH: self.jira_sysadmin = JIRA(oauth={ 'access_token': '4ul1ETSFo7ybbIxAxzyRal39cTrwEGFv', 'access_token_secret': 'K83jBZnjnuVRcfjBflrKyThJa0KSjSs2', 'consumer_key': CONSUMER_KEY, 'key_cert': KEY_CERT_DATA}, logging=False, max_retries=self.max_retries) else: if self.CI_JIRA_ADMIN: self.jira_sysadmin = JIRA(self.CI_JIRA_URL, basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), logging=False, validate=True, max_retries=self.max_retries) else: self.jira_sysadmin = JIRA(self.CI_JIRA_URL, logging=False, max_retries=self.max_retries) if OAUTH: self.jira_normal = JIRA(oauth={ 'access_token': 'ZVDgYDyIQqJY8IFlQ446jZaURIz5ECiB', 'access_token_secret': '5WbLBybPDg1lqqyFjyXSCsCtAWTwz1eD', 'consumer_key': CONSUMER_KEY, 'key_cert': KEY_CERT_DATA}) else: if self.CI_JIRA_ADMIN: self.jira_normal = JIRA(self.CI_JIRA_URL, basic_auth=(self.CI_JIRA_USER, self.CI_JIRA_USER_PASSWORD), validate=True, logging=False, max_retries=self.max_retries) else: self.jira_normal = JIRA(self.CI_JIRA_URL, validate=True, logging=False, max_retries=self.max_retries) # now we need some data to start with for the tests # jira project key is max 10 chars, no letter. # [0] always "Z" # [1-6] username running the tests (hope we will not collide) # [7-8] python version A=0, B=1,.. # [9] A,B -- we may need more than one project """ `jid` is important for avoiding concurency problems when executing tests in parallel as we have only one test instance. jid length must be less than 9 characters because we may append another one and the JIRA Project key length limit is 10. Tests run in parallel: * git branches master or developer, git pr or developers running tests outside Travis * Travis is using "Travis" username https://docs.travis-ci.com/user/environment-variables/ """ self.jid = get_unique_project_name() self.project_a = self.jid + 'A' # old XSS self.project_a_name = "Test user=%s key=%s A" \ % (getpass.getuser(), self.project_a) self.project_b = self.jid + 'B' # old BULK self.project_b_name = "Test user=%s key=%s B" \ % (getpass.getuser(), self.project_b) # TODO(ssbarnea): find a way to prevent SecurityTokenMissing for On Demand # https://jira.atlassian.com/browse/JRA-39153 try: self.jira_admin.project(self.project_a) except Exception as e: logging.warning(e) pass else: try: self.jira_admin.delete_project(self.project_a) except Exception as e: pass try: self.jira_admin.project(self.project_b) except Exception as e: logging.warning(e) pass else: try: self.jira_admin.delete_project(self.project_b) except Exception as e: pass # wait for the project to be deleted for i in range(1, 20): try: self.jira_admin.project(self.project_b) except Exception as e: print(e) break sleep(2) try: self.jira_admin.create_project(self.project_a, self.project_a_name) except Exception: # we care only for the project to exist pass self.project_a_id = self.jira_admin.project(self.project_a).id # except Exception as e: # logging.warning("Got %s" % e) # try: # assert self.jira_admin.create_project(self.project_b, # self.project_b_name) is True, "Failed to create %s" % # self.project_b try: self.jira_admin.create_project(self.project_b, self.project_b_name) except Exception: # we care only for the project to exist pass sleep(1) # keep it here as often JIRA will report the # project as missing even after is created self.project_b_issue1_obj = self.jira_admin.create_issue(project=self.project_b, summary='issue 1 from %s' % self.project_b, issuetype=self.CI_JIRA_ISSUE) self.project_b_issue1 = self.project_b_issue1_obj.key self.project_b_issue2_obj = self.jira_admin.create_issue(project=self.project_b, summary='issue 2 from %s' % self.project_b, issuetype={'name': self.CI_JIRA_ISSUE}) self.project_b_issue2 = self.project_b_issue2_obj.key self.project_b_issue3_obj = self.jira_admin.create_issue(project=self.project_b, summary='issue 3 from %s' % self.project_b, issuetype={'name': self.CI_JIRA_ISSUE}) self.project_b_issue3 = self.project_b_issue3_obj.key except Exception as e: logging.exception("Basic test setup failed") self.initialized = 1 py.test.exit("FATAL: %s\n%s" % (e, traceback.format_exc())) if not hasattr(self, 'jira_normal') or not hasattr(self, 'jira_admin'): py.test.exit("FATAL: WTF!?") self.initialized = 1 else: # already exist but we need to be sure it was initialized counter = 0 while not self.initialized: sleep(1) counter += 1 if counter > 60: logging.fatal("Something is clearly not right with " + "initialization, killing the tests to prevent a " + "deadlock.") sys.exit(3) def find_by_key(seq, key): for seq_item in seq: if seq_item['key'] == key: return seq_item def find_by_key_value(seq, key): for seq_item in seq: if seq_item.key == key: return seq_item def find_by_id(seq, id): for seq_item in seq: if seq_item.id == id: return seq_item def find_by_name(seq, name): for seq_item in seq: if seq_item['name'] == name: return seq_item @flaky class UniversalResourceTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin self.test_manager = JiraTestManager() def test_universal_find_existing_resource(self): resource = self.jira.find('issue/{0}', self.test_manager.project_b_issue1) issue = self.jira.issue(self.test_manager.project_b_issue1) self.assertEqual(resource.self, issue.self) self.assertEqual(resource.key, issue.key) def test_find_invalid_resource_raises_exception(self): with self.assertRaises(JIRAError) as cm: self.jira.find('woopsydoodle/{0}', '666') ex = cm.exception # py26,27,34 gets 404 but on py33 gets 400 assert ex.status_code in [400, 404] self.assertIsNotNone(ex.text) self.assertRegex(ex.url, '^https?://.*/rest/api/(2|latest)/woopsydoodle/666$') def test_pickling_resource(self): resource = self.jira.find('issue/{0}', self.test_manager.project_b_issue1) pickled = pickle.dumps(resource.raw) unpickled = pickle.loads(pickled) cls = cls_for_resource(unpickled['self']) unpickled_instance = cls(self.jira._options, self.jira._session, raw=pickle.loads(pickled)) self.assertEqual(resource.key, unpickled_instance.key) self.assertTrue(resource == unpickled_instance) @flaky class ResourceTests(unittest.TestCase): def setUp(self): pass def test_cls_for_resource(self): self.assertEqual(cls_for_resource('https://jira.atlassian.com/rest/\ api/latest/issue/JRA-1330'), Issue) self.assertEqual(cls_for_resource('http://localhost:2990/jira/rest/\ api/latest/project/BULK'), Project) self.assertEqual(cls_for_resource('http://imaginary-jira.com/rest/\ api/latest/project/IMG/role/10002'), Role) self.assertEqual(cls_for_resource('http://customized-jira.com/rest/\ plugin-resource/4.5/json/getMyObject'), Resource) @flaky class ApplicationPropertiesTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_application_properties(self): props = self.jira.application_properties() for p in props: self.assertIsInstance(p, dict) self.assertTrue(set(p.keys()).issuperset(set(['type', 'name', 'value', 'key', 'id']))) def test_application_property(self): clone_prefix = self.jira.application_properties( key='jira.lf.text.headingcolour') self.assertEqual(clone_prefix['value'], '#292929') @pytest.mark.skipif(_non_parallel, reason="avoid concurrency conflict") def test_set_application_property(self): prop = 'jira.lf.favicon.hires.url' valid_value = '/jira-favicon-hires.png' invalid_value = '/Tjira-favicon-hires.png' self.jira.set_application_property(prop, invalid_value) self.assertEqual(self.jira.application_properties(key=prop)['value'], invalid_value) self.jira.set_application_property(prop, valid_value) self.assertEqual(self.jira.application_properties(key=prop)['value'], valid_value) def test_setting_bad_property_raises(self): prop = 'random.nonexistent.property' self.assertRaises(JIRAError, self.jira.set_application_property, prop, '666') @flaky class AttachmentTests(unittest.TestCase): def setUp(self): self.test_manager = JiraTestManager() self.jira = JiraTestManager().jira_admin self.project_b = self.test_manager.project_b self.issue_1 = self.test_manager.project_b_issue1 self.attachment = None def test_0_attachment_meta(self): meta = self.jira.attachment_meta() self.assertTrue(meta['enabled']) self.assertEqual(meta['uploadLimit'], 10485760) @unittest.skip("TBD: investigate failure") def test_1_add_remove_attachment(self): issue = self.jira.issue(self.issue_1) attachment = self.jira.add_attachment(issue, open(TEST_ATTACH_PATH, 'rb'), "new test attachment") new_attachment = self.jira.attachment(attachment.id) msg = "attachment %s of issue %s" % (new_attachment.__dict__, issue) self.assertEqual( new_attachment.filename, 'new test attachment', msg=msg) self.assertEqual( new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg) assert attachment.delete() is None @flaky class ComponentTests(unittest.TestCase): def setUp(self): self.test_manager = JiraTestManager() self.jira = JiraTestManager().jira_admin self.project_b = self.test_manager.project_b self.issue_1 = self.test_manager.project_b_issue1 self.issue_2 = self.test_manager.project_b_issue2 def test_2_create_component(self): proj = self.jira.project(self.project_b) name = "project-%s-component-%s" % (proj, rndstr()) component = self.jira.create_component(name, proj, description='test!!', assigneeType='COMPONENT_LEAD', isAssigneeTypeValid=False) self.assertEqual(component.name, name) self.assertEqual(component.description, 'test!!') self.assertEqual(component.assigneeType, 'COMPONENT_LEAD') self.assertFalse(component.isAssigneeTypeValid) component.delete() # Components field can't be modified from issue.update # def test_component_count_related_issues(self): # component = self.jira.create_component('PROJECT_B_TEST',self.project_b, description='test!!', # assigneeType='COMPONENT_LEAD', isAssigneeTypeValid=False) # issue1 = self.jira.issue(self.issue_1) # issue2 = self.jira.issue(self.issue_2) # (issue1.update ({'components': ['PROJECT_B_TEST']})) # (issue2.update (components = ['PROJECT_B_TEST'])) # issue_count = self.jira.component_count_related_issues(component.id) # self.assertEqual(issue_count, 2) # component.delete() def test_3_update(self): try: components = self.jira.project_components(self.project_b) for component in components: if component.name == 'To be updated': component.delete() break except Exception: # We ignore errors as this code intends only to prepare for # component creation raise name = 'component-' + rndstr() component = self.jira.create_component(name, self.project_b, description='stand by!', leadUserName=self.test_manager.CI_JIRA_ADMIN) name = 'renamed-' + name component.update(name=name, description='It is done.', leadUserName=self.test_manager.CI_JIRA_ADMIN) self.assertEqual(component.name, name) self.assertEqual(component.description, 'It is done.') self.assertEqual(component.lead.name, self.test_manager.CI_JIRA_ADMIN) component.delete() def test_4_delete(self): component = self.jira.create_component('To be deleted', self.project_b, description='not long for this world') myid = component.id component.delete() self.assertRaises(JIRAError, self.jira.component, myid) @flaky class CustomFieldOptionTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin @not_on_custom_jira_instance def test_custom_field_option(self): option = self.jira.custom_field_option('10001') self.assertEqual(option.value, 'To Do') @not_on_custom_jira_instance @flaky class DashboardTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_dashboards(self): dashboards = self.jira.dashboards() self.assertEqual(len(dashboards), 3) def test_dashboards_filter(self): dashboards = self.jira.dashboards(filter='my') self.assertEqual(len(dashboards), 2) self.assertEqual(dashboards[0].id, '10101') def test_dashboards_startat(self): dashboards = self.jira.dashboards(startAt=1, maxResults=1) self.assertEqual(len(dashboards), 1) def test_dashboards_maxresults(self): dashboards = self.jira.dashboards(maxResults=1) self.assertEqual(len(dashboards), 1) def test_dashboard(self): dashboard = self.jira.dashboard('10101') self.assertEqual(dashboard.id, '10101') self.assertEqual(dashboard.name, 'Another test dashboard') @not_on_custom_jira_instance @flaky class FieldsTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_fields(self): fields = self.jira.fields() self.assertGreater(len(fields), 10) @flaky class FilterTests(unittest.TestCase): def setUp(self): self.test_manager = JiraTestManager() self.jira = JiraTestManager().jira_admin self.project_b = self.test_manager.project_b self.issue_1 = self.test_manager.project_b_issue1 self.issue_2 = self.test_manager.project_b_issue2 def test_filter(self): jql = "project = %s and component is not empty" % self.project_b name = 'same filter ' + rndstr() myfilter = self.jira.create_filter(name=name, description="just some new test filter", jql=jql, favourite=False) self.assertEqual(myfilter.name, name) self.assertEqual(myfilter.owner.name, self.test_manager.CI_JIRA_ADMIN) myfilter.delete() def test_favourite_filters(self): # filters = self.jira.favourite_filters() jql = "project = %s and component is not empty" % self.project_b name = "filter-to-fav-" + rndstr() myfilter = self.jira.create_filter(name=name, description="just some new test filter", jql=jql, favourite=True) new_filters = self.jira.favourite_filters() assert name in [f.name for f in new_filters] myfilter.delete() @not_on_custom_jira_instance @flaky class GroupsTest(unittest.TestCase): def setUp(self): self.test_manager = JiraTestManager() self.jira = self.test_manager.jira_admin def test_groups(self): groups = self.jira.groups() self.assertGreater(len(groups), 0) def test_groups_for_users(self): groups = self.jira.groups('jira-users') self.assertGreater(len(groups), 0) @flaky class IssueTests(unittest.TestCase): def setUp(self): self.test_manager = JiraTestManager() self.jira = JiraTestManager().jira_admin self.jira_normal = JiraTestManager().jira_normal self.project_b = self.test_manager.project_b self.project_a = self.test_manager.project_a self.issue_1 = self.test_manager.project_b_issue1 self.issue_2 = self.test_manager.project_b_issue2 self.issue_3 = self.test_manager.project_b_issue3 def test_issue(self): issue = self.jira.issue(self.issue_1) self.assertEqual(issue.key, self.issue_1) self.assertEqual(issue.fields.summary, 'issue 1 from %s' % self.project_b) @unittest.skip("disabled as it seems to be ignored by jira, returning all") def test_issue_field_limiting(self): issue = self.jira.issue(self.issue_2, fields='summary,comment') self.assertEqual(issue.fields.summary, 'issue 2 from %s' % self.project_b) comment1 = self.jira.add_comment(issue, 'First comment') comment2 = self.jira.add_comment(issue, 'Second comment') comment3 = self.jira.add_comment(issue, 'Third comment') self.jira.issue(self.issue_2, fields='summary,comment') logging.warning(issue.raw['fields']) self.assertFalse(hasattr(issue.fields, 'reporter')) self.assertFalse(hasattr(issue.fields, 'progress')) comment1.delete() comment2.delete() comment3.delete() def test_issue_equal(self): issue1 = self.jira.issue(self.issue_1) issue2 = self.jira.issue(self.issue_2) issues = self.jira.search_issues('key=%s' % self.issue_1) self.assertTrue(issue1 == issues[0]) self.assertFalse(issue2 == issues[0]) def test_issue_expandos(self): issue = self.jira.issue(self.issue_1, expand='editmeta,schema') self.assertTrue(hasattr(issue, 'editmeta')) self.assertTrue(hasattr(issue, 'schema')) # testing for changelog is not reliable because it may exist or not based on test order # self.assertFalse(hasattr(issue, 'changelog')) @not_on_custom_jira_instance def test_create_issue_with_fieldargs(self): issue = self.jira.create_issue(project=self.project_b, summary='Test issue created', description='blahery', issuetype={'name': 'Bug'}) # customfield_10022='XSS' self.assertEqual(issue.fields.summary, 'Test issue created') self.assertEqual(issue.fields.description, 'blahery') self.assertEqual(issue.fields.issuetype.name, 'Bug') self.assertEqual(issue.fields.project.key, self.project_b) # self.assertEqual(issue.fields.customfield_10022, 'XSS') issue.delete() @not_on_custom_jira_instance def test_create_issue_with_fielddict(self): fields = { 'project': { 'key': self.project_b}, 'summary': 'Issue created from field dict', 'description': "Some new issue for test", 'issuetype': { 'name': 'Bug'}, # 'customfield_10022': 'XSS', 'priority': { 'name': 'Major'}} issue = self.jira.create_issue(fields=fields) self.assertEqual(issue.fields.summary, 'Issue created from field dict') self.assertEqual(issue.fields.description, "Some new issue for test") self.assertEqual(issue.fields.issuetype.name, 'Bug') self.assertEqual(issue.fields.project.key, self.project_b) # self.assertEqual(issue.fields.customfield_10022, 'XSS') self.assertEqual(issue.fields.priority.name, 'Major') issue.delete() @not_on_custom_jira_instance def test_create_issue_without_prefetch(self): issue = self.jira.create_issue(prefetch=False, project=self.project_b, summary='Test issue created', description='blahery', issuetype={'name': 'Bug'} ) # customfield_10022='XSS' assert hasattr(issue, 'self') assert hasattr(issue, 'raw') assert 'fields' not in issue.raw issue.delete() @not_on_custom_jira_instance def test_create_issues(self): field_list = [{ 'project': { 'key': self.project_b}, 'summary': 'Issue created via bulk create #1', 'description': "Some new issue for test", 'issuetype': { 'name': 'Bug'}, # 'customfield_10022': 'XSS', 'priority': { 'name': 'Major'}}, { 'project': { 'key': self.project_a}, 'issuetype': { 'name': 'Bug'}, 'summary': 'Issue created via bulk create #2', 'description': "Another new issue for bulk test", 'priority': { 'name': 'Major'}}] issues = self.jira.create_issues(field_list=field_list) self.assertEqual(issues[0]['issue'].fields.summary, 'Issue created via bulk create #1') self.assertEqual(issues[0]['issue'].fields.description, "Some new issue for test") self.assertEqual(issues[0]['issue'].fields.issuetype.name, 'Bug') self.assertEqual(issues[0]['issue'].fields.project.key, self.project_b) self.assertEqual(issues[0]['issue'].fields.priority.name, 'Major') self.assertEqual(issues[1]['issue'].fields.summary, 'Issue created via bulk create #2') self.assertEqual(issues[1]['issue'].fields.description, "Another new issue for bulk test") self.assertEqual(issues[1]['issue'].fields.issuetype.name, 'Bug') self.assertEqual(issues[1]['issue'].fields.project.key, self.project_a) self.assertEqual(issues[1]['issue'].fields.priority.name, 'Major') for issue in issues: issue['issue'].delete() @not_on_custom_jira_instance def test_create_issues_one_failure(self): field_list = [{ 'project': { 'key': self.project_b}, 'summary': 'Issue created via bulk create #1', 'description': "Some new issue for test", 'issuetype': { 'name': 'Bug'}, # 'customfield_10022': 'XSS', 'priority': { 'name': 'Major'}}, {'project': { 'key': self.project_a}, 'issuetype': { 'name': 'InvalidIssueType'}, 'summary': 'This issue will not succeed', 'description': "Should not be seen.", 'priority': { 'name': 'Blah'}}, {'project': { 'key': self.project_a}, 'issuetype': { 'name': 'Bug'}, 'summary': 'However, this one will.', 'description': "Should be seen.", 'priority': { 'name': 'Major'}}] issues = self.jira.create_issues(field_list=field_list) self.assertEqual(issues[0]['issue'].fields.summary, 'Issue created via bulk create #1') self.assertEqual(issues[0]['issue'].fields.description, "Some new issue for test") self.assertEqual(issues[0]['issue'].fields.issuetype.name, 'Bug') self.assertEqual(issues[0]['issue'].fields.project.key, self.project_b) self.assertEqual(issues[0]['issue'].fields.priority.name, 'Major') self.assertEqual(issues[0]['error'], None) self.assertEqual(issues[1]['issue'], None) self.assertEqual(issues[1]['error'], {'issuetype': 'issue type is required'}) self.assertEqual(issues[1]['input_fields'], field_list[1]) self.assertEqual(issues[2]['issue'].fields.summary, 'However, this one will.') self.assertEqual(issues[2]['issue'].fields.description, "Should be seen.") self.assertEqual(issues[2]['issue'].fields.issuetype.name, 'Bug') self.assertEqual(issues[2]['issue'].fields.project.key, self.project_a) self.assertEqual(issues[2]['issue'].fields.priority.name, 'Major') self.assertEqual(issues[2]['error'], None) self.assertEqual(len(issues), 3) for issue in issues: if issue['issue'] is not None: issue['issue'].delete() @not_on_custom_jira_instance def test_create_issues_without_prefetch(self): field_list = [dict(project=self.project_b, summary='Test issue created', description='blahery', issuetype={'name': 'Bug'}), dict(project=self.project_a, summary='Test issue #2', description='fooery', issuetype={'name': 'Bug'})] issues = self.jira.create_issues(field_list, prefetch=False) assert hasattr(issues[0]['issue'], 'self') assert hasattr(issues[0]['issue'], 'raw') assert hasattr(issues[1]['issue'], 'self') assert hasattr(issues[1]['issue'], 'raw') assert 'fields' not in issues[0]['issue'].raw assert 'fields' not in issues[1]['issue'].raw for issue in issues: issue['issue'].delete() @not_on_custom_jira_instance def test_update_with_fieldargs(self): issue = self.jira.create_issue(project=self.project_b, summary='Test issue for updating', description='Will be updated shortly', issuetype={'name': 'Bug'}) # customfield_10022='XSS') issue.update(summary='Updated summary', description='Now updated', issuetype={'name': 'Improvement'}) self.assertEqual(issue.fields.summary, 'Updated summary') self.assertEqual(issue.fields.description, 'Now updated') self.assertEqual(issue.fields.issuetype.name, 'Improvement') # self.assertEqual(issue.fields.customfield_10022, 'XSS') self.assertEqual(issue.fields.project.key, self.project_b) issue.delete() @not_on_custom_jira_instance def test_update_with_fielddict(self): issue = self.jira.create_issue(project=self.project_b, summary='Test issue for updating', description='Will be updated shortly', issuetype={'name': 'Bug'}) fields = { 'summary': 'Issue is updated', 'description': "it sure is", 'issuetype': { 'name': 'Improvement'}, # 'customfield_10022': 'DOC', 'priority': { 'name': 'Major'}} issue.update(fields=fields) self.assertEqual(issue.fields.summary, 'Issue is updated') self.assertEqual(issue.fields.description, 'it sure is') self.assertEqual(issue.fields.issuetype.name, 'Improvement') # self.assertEqual(issue.fields.customfield_10022, 'DOC') self.assertEqual(issue.fields.priority.name, 'Major') issue.delete() def test_update_with_label(self): issue = self.jira.create_issue(project=self.project_b, summary='Test issue for updating labels', description='Label testing', issuetype=self.test_manager.CI_JIRA_ISSUE) labelarray = ['testLabel'] fields = { 'labels': labelarray} issue.update(fields=fields) self.assertEqual(issue.fields.labels, ['testLabel']) def test_update_with_bad_label(self): issue = self.jira.create_issue(project=self.project_b, summary='Test issue for updating labels', description='Label testing', issuetype=self.test_manager.CI_JIRA_ISSUE) issue.fields.labels.append('this should not work') fields = { 'labels': issue.fields.labels} self.assertRaises(JIRAError, issue.update, fields=fields) @not_on_custom_jira_instance def test_update_with_notify_false(self): issue = self.jira.create_issue(project=self.project_b, summary='Test issue for updating', description='Will be updated shortly', issuetype={'name': 'Bug'}) issue.update(notify=False, description='Now updated, but silently') self.assertEqual(issue.fields.description, 'Now updated, but silently') issue.delete() def test_delete(self): issue = self.jira.create_issue(project=self.project_b, summary='Test issue created', description='Not long for this world', issuetype=self.test_manager.CI_JIRA_ISSUE) key = issue.key issue.delete() self.assertRaises(JIRAError, self.jira.issue, key) @not_on_custom_jira_instance def test_createmeta(self): meta = self.jira.createmeta() ztravisdeb_proj = find_by_key(meta['projects'], self.project_b) # we assume that this project should allow at least one issue type self.assertGreaterEqual(len(ztravisdeb_proj['issuetypes']), 1) @not_on_custom_jira_instance def test_createmeta_filter_by_projectkey_and_name(self): meta = self.jira.createmeta(projectKeys=self.project_b, issuetypeNames='Bug') self.assertEqual(len(meta['projects']), 1) self.assertEqual(len(meta['projects'][0]['issuetypes']), 1) @not_on_custom_jira_instance def test_createmeta_filter_by_projectkeys_and_name(self): meta = self.jira.createmeta(projectKeys=(self.project_a, self.project_b), issuetypeNames='Improvement') self.assertEqual(len(meta['projects']), 2) for project in meta['projects']: self.assertEqual(len(project['issuetypes']), 1) @not_on_custom_jira_instance def test_createmeta_filter_by_id(self): projects = self.jira.projects() proja = find_by_key_value(projects, self.project_a) projb = find_by_key_value(projects, self.project_b) meta = self.jira.createmeta(projectIds=(proja.id, projb.id), issuetypeIds=('3', '4', '5')) self.assertEqual(len(meta['projects']), 2) for project in meta['projects']: self.assertEqual(len(project['issuetypes']), 3) def test_createmeta_expando(self): # limit to SCR project so the call returns promptly meta = self.jira.createmeta(projectKeys=self.project_b, expand='projects.issuetypes.fields') self.assertTrue('fields' in meta['projects'][0]['issuetypes'][0]) def test_assign_issue(self): self.assertTrue(self.jira.assign_issue(self.issue_1, self.test_manager.CI_JIRA_ADMIN)) self.assertEqual(self.jira.issue(self.issue_1).fields.assignee.name, self.test_manager.CI_JIRA_ADMIN) def test_assign_issue_with_issue_obj(self): issue = self.jira.issue(self.issue_1) x = self.jira.assign_issue(issue, self.test_manager.CI_JIRA_ADMIN) self.assertTrue(x) self.assertEqual(self.jira.issue(self.issue_1).fields.assignee.name, self.test_manager.CI_JIRA_ADMIN) def test_assign_to_bad_issue_raises(self): self.assertRaises(JIRAError, self.jira.assign_issue, 'NOPE-1', 'notauser') def test_comments(self): for issue in [self.issue_1, self.jira.issue(self.issue_2)]: self.jira.issue(issue) comment1 = self.jira.add_comment(issue, 'First comment') comment2 = self.jira.add_comment(issue, 'Second comment') comments = self.jira.comments(issue) assert comments[0].body == 'First comment' assert comments[1].body == 'Second comment' comment1.delete() comment2.delete() comments = self.jira.comments(issue) assert len(comments) == 0 def test_add_comment(self): comment = self.jira.add_comment(self.issue_3, 'a test comment!', visibility={'type': 'role', 'value': 'Administrators'}) self.assertEqual(comment.body, 'a test comment!') self.assertEqual(comment.visibility.type, 'role') self.assertEqual(comment.visibility.value, 'Administrators') comment.delete() def test_add_comment_with_issue_obj(self): issue = self.jira.issue(self.issue_3) comment = self.jira.add_comment(issue, 'a new test comment!', visibility={'type': 'role', 'value': 'Administrators'}) self.assertEqual(comment.body, 'a new test comment!') self.assertEqual(comment.visibility.type, 'role') self.assertEqual(comment.visibility.value, 'Administrators') comment.delete() def test_update_comment(self): comment = self.jira.add_comment(self.issue_3, 'updating soon!') comment.update(body='updated!') self.assertEqual(comment.body, 'updated!') # self.assertEqual(comment.visibility.type, 'role') # self.assertEqual(comment.visibility.value, 'Administrators') comment.delete() def test_editmeta(self): for i in (self.issue_1, self.issue_2): meta = self.jira.editmeta(i) self.assertTrue('assignee' in meta['fields']) self.assertTrue('attachment' in meta['fields']) self.assertTrue('comment' in meta['fields']) self.assertTrue('components' in meta['fields']) self.assertTrue('description' in meta['fields']) self.assertTrue('duedate' in meta['fields']) self.assertTrue('environment' in meta['fields']) self.assertTrue('fixVersions' in meta['fields']) self.assertTrue('issuelinks' in meta['fields']) self.assertTrue('issuetype' in meta['fields']) self.assertTrue('labels' in meta['fields']) self.assertTrue('versions' in meta['fields']) # Nothing from remote link works # def test_remote_links(self): # self.jira.add_remote_link ('ZTRAVISDEB-3', globalId='python-test:story.of.horse.riding', # links = self.jira.remote_links('QA-44') # self.assertEqual(len(links), 1) # links = self.jira.remote_links('BULK-1') # self.assertEqual(len(links), 0) # # @unittest.skip("temporary disabled") # def test_remote_links_with_issue_obj(self): # issue = self.jira.issue('QA-44') # links = self.jira.remote_links(issue) # self.assertEqual(len(links), 1) # issue = self.jira.issue('BULK-1') # links = self.jira.remote_links(issue) # self.assertEqual(len(links), 0) # # @unittest.skip("temporary disabled") # def test_remote_link(self): # link = self.jira.remote_link('QA-44', '10000') # self.assertEqual(link.id, 10000) # self.assertTrue(hasattr(link, 'globalId')) # self.assertTrue(hasattr(link, 'relationship')) # # @unittest.skip("temporary disabled") # def test_remote_link_with_issue_obj(self): # issue = self.jira.issue('QA-44') # link = self.jira.remote_link(issue, '10000') # self.assertEqual(link.id, 10000) # self.assertTrue(hasattr(link, 'globalId')) # self.assertTrue(hasattr(link, 'relationship')) # # @unittest.skip("temporary disabled") # def test_add_remote_link(self): # link = self.jira.add_remote_link('BULK-3', globalId='python-test:story.of.horse.riding', # object={'url': 'http://google.com', 'title': 'googlicious!'}, # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') # creation response doesn't include full remote link info, so we fetch it again using the new internal ID # link = self.jira.remote_link('BULK-3', link.id) # self.assertEqual(link.application.name, 'far too silly') # self.assertEqual(link.application.type, 'sketch') # self.assertEqual(link.object.url, 'http://google.com') # self.assertEqual(link.object.title, 'googlicious!') # self.assertEqual(link.relationship, 'mousebending') # self.assertEqual(link.globalId, 'python-test:story.of.horse.riding') # # @unittest.skip("temporary disabled") # def test_add_remote_link_with_issue_obj(self): # issue = self.jira.issue('BULK-3') # link = self.jira.add_remote_link(issue, globalId='python-test:story.of.horse.riding', # object={'url': 'http://google.com', 'title': 'googlicious!'}, # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') # creation response doesn't include full remote link info, so we fetch it again using the new internal ID # link = self.jira.remote_link(issue, link.id) # self.assertEqual(link.application.name, 'far too silly') # self.assertEqual(link.application.type, 'sketch') # self.assertEqual(link.object.url, 'http://google.com') # self.assertEqual(link.object.title, 'googlicious!') # self.assertEqual(link.relationship, 'mousebending') # self.assertEqual(link.globalId, 'python-test:story.of.horse.riding') # # @unittest.skip("temporary disabled") # def test_update_remote_link(self): # link = self.jira.add_remote_link('BULK-3', globalId='python-test:story.of.horse.riding', # object={'url': 'http://google.com', 'title': 'googlicious!'}, # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') # creation response doesn't include full remote link info, so we fetch it again using the new internal ID # link = self.jira.remote_link('BULK-3', link.id) # link.update(object={'url': 'http://yahoo.com', 'title': 'yahooery'}, globalId='python-test:updated.id', # relationship='cheesing') # self.assertEqual(link.globalId, 'python-test:updated.id') # self.assertEqual(link.relationship, 'cheesing') # self.assertEqual(link.object.url, 'http://yahoo.com') # self.assertEqual(link.object.title, 'yahooery') # link.delete() # # @unittest.skip("temporary disabled") # def test_delete_remove_link(self): # link = self.jira.add_remote_link('BULK-3', globalId='python-test:story.of.horse.riding', # object={'url': 'http://google.com', 'title': 'googlicious!'}, # application={'name': 'far too silly', 'type': 'sketch'}, relationship='mousebending') # _id = link.id # link.delete() # self.assertRaises(JIRAError, self.jira.remote_link, 'BULK-3', _id) def test_transitioning(self): # we check with both issue-as-string or issue-as-object transitions = [] for issue in [self.issue_2, self.jira.issue(self.issue_2)]: transitions = self.jira.transitions(issue) self.assertTrue(transitions) self.assertTrue('id' in transitions[0]) self.assertTrue('name' in transitions[0]) self.assertTrue(transitions, msg="Expecting at least one transition") # we test getting a single transition transition = self.jira.transitions(self.issue_2, transitions[0]['id'])[0] self.assertDictEqual(transition, transitions[0]) # we test the expand of fields transition = self.jira.transitions(self.issue_2, transitions[0]['id'], expand='transitions.fields')[0] self.assertTrue('fields' in transition) # Testing of transition with field assignment is disabled now because default workflows do not have it. # self.jira.transition_issue(issue, transitions[0]['id'], assignee={'name': self.test_manager.CI_JIRA_ADMIN}) # issue = self.jira.issue(issue.key) # self.assertEqual(issue.fields.assignee.name, self.test_manager.CI_JIRA_ADMIN) # # fields = { # 'assignee': { # 'name': self.test_manager.CI_JIRA_USER # } # } # transitions = self.jira.transitions(issue.key) # self.assertTrue(transitions) # any issue should have at least one transition available to it # transition_id = transitions[0]['id'] # # self.jira.transition_issue(issue.key, transition_id, fields=fields) # issue = self.jira.issue(issue.key) # self.assertEqual(issue.fields.assignee.name, self.test_manager.CI_JIRA_USER) # self.assertEqual(issue.fields.status.id, transition_id) def test_votes(self): self.jira_normal.remove_vote(self.issue_1) # not checking the result on this votes = self.jira.votes(self.issue_1) self.assertEqual(votes.votes, 0) self.jira_normal.add_vote(self.issue_1) new_votes = self.jira.votes(self.issue_1) assert votes.votes + 1 == new_votes.votes self.jira_normal.remove_vote(self.issue_1) new_votes = self.jira.votes(self.issue_1) assert votes.votes == new_votes.votes def test_votes_with_issue_obj(self): issue = self.jira_normal.issue(self.issue_1) self.jira_normal.remove_vote(issue) # not checking the result on this votes = self.jira.votes(issue) self.assertEqual(votes.votes, 0) self.jira_normal.add_vote(issue) new_votes = self.jira.votes(issue) assert votes.votes + 1 == new_votes.votes self.jira_normal.remove_vote(issue) new_votes = self.jira.votes(issue) assert votes.votes == new_votes.votes def test_add_remove_watcher(self): # removing it in case it exists, so we know its state self.jira.remove_watcher(self.issue_1, self.test_manager.CI_JIRA_USER) init_watchers = self.jira.watchers(self.issue_1).watchCount # adding a new watcher self.jira.add_watcher(self.issue_1, self.test_manager.CI_JIRA_USER) self.assertEqual(self.jira.watchers(self.issue_1).watchCount, init_watchers + 1) # now we verify that remove does indeed remove watchers self.jira.remove_watcher(self.issue_1, self.test_manager.CI_JIRA_USER) new_watchers = self.jira.watchers(self.issue_1).watchCount self.assertEqual(init_watchers, new_watchers) @not_on_custom_jira_instance def test_agile(self): uniq = rndstr() board_name = 'board-' + uniq sprint_name = 'sprint-' + uniq b = self.jira.create_board(board_name, self.project_a) assert isinstance(b.id, integer_types) s = self.jira.create_sprint(sprint_name, b.id) assert isinstance(s.id, integer_types) assert s.name == sprint_name assert s.state == 'FUTURE' self.jira.add_issues_to_sprint(s.id, [self.issue_1]) sprint_field_name = "Sprint" sprint_field_id = [f['schema']['customId'] for f in self.jira.fields() if f['name'] == sprint_field_name][0] sprint_customfield = "customfield_" + str(sprint_field_id) updated_issue_1 = self.jira.issue(self.issue_1) serialised_sprint = getattr(updated_issue_1.fields, sprint_customfield)[0] # Too hard to serialise the sprint object. Performing simple regex match instead. assert re.search('\[id=' + str(s.id) + ',', serialised_sprint) # self.jira.add_issues_to_sprint(s.id, self.issue_2) # self.jira.rank(self.issue_2, self.issue_1) sleep(2) # avoid https://travis-ci.org/pycontribs/jira/jobs/176561534#L516 s.delete() sleep(2) b.delete() # self.jira.delete_board(b.id) def test_worklogs(self): worklog = self.jira.add_worklog(self.issue_1, '2h') worklogs = self.jira.worklogs(self.issue_1) self.assertEqual(len(worklogs), 1) worklog.delete() def test_worklogs_with_issue_obj(self): issue = self.jira.issue(self.issue_1) worklog = self.jira.add_worklog(issue, '2h') worklogs = self.jira.worklogs(issue) self.assertEqual(len(worklogs), 1) worklog.delete() def test_worklog(self): worklog = self.jira.add_worklog(self.issue_1, '1d 2h') new_worklog = self.jira.worklog(self.issue_1, str(worklog)) self.assertEqual(new_worklog.author.name, self.test_manager.CI_JIRA_ADMIN) self.assertEqual(new_worklog.timeSpent, '1d 2h') worklog.delete() def test_worklog_with_issue_obj(self): issue = self.jira.issue(self.issue_1) worklog = self.jira.add_worklog(issue, '1d 2h') new_worklog = self.jira.worklog(issue, str(worklog)) self.assertEqual(new_worklog.author.name, self.test_manager.CI_JIRA_ADMIN) self.assertEqual(new_worklog.timeSpent, '1d 2h') worklog.delete() def test_add_worklog(self): worklog_count = len(self.jira.worklogs(self.issue_2)) worklog = self.jira.add_worklog(self.issue_2, '2h') self.assertIsNotNone(worklog) self.assertEqual(len(self.jira.worklogs(self.issue_2)), worklog_count + 1) worklog.delete() def test_add_worklog_with_issue_obj(self): issue = self.jira.issue(self.issue_2) worklog_count = len(self.jira.worklogs(issue)) worklog = self.jira.add_worklog(issue, '2h') self.assertIsNotNone(worklog) self.assertEqual(len(self.jira.worklogs(issue)), worklog_count + 1) worklog.delete() def test_update_and_delete_worklog(self): worklog = self.jira.add_worklog(self.issue_3, '3h') issue = self.jira.issue(self.issue_3, fields='worklog,timetracking') worklog.update(comment='Updated!', timeSpent='2h') self.assertEqual(worklog.comment, 'Updated!') # rem_estimate = issue.fields.timetracking.remainingEstimate self.assertEqual(worklog.timeSpent, '2h') issue = self.jira.issue(self.issue_3, fields='worklog,timetracking') self.assertEqual(issue.fields.timetracking.remainingEstimate, "1h") worklog.delete() issue = self.jira.issue(self.issue_3, fields='worklog,timetracking') self.assertEqual(issue.fields.timetracking.remainingEstimate, "3h") @flaky class IssueLinkTests(unittest.TestCase): def setUp(self): self.manager = JiraTestManager() self.link_types = self.manager.jira_admin.issue_link_types() def test_issue_link(self): self.link = self.manager.jira_admin.issue_link_type(self.link_types[0].id) link = self.link # Duplicate outward self.assertEqual(link.id, self.link_types[0].id) def test_create_issue_link(self): self.manager.jira_admin.create_issue_link(self.link_types[0].outward, JiraTestManager().project_b_issue1, JiraTestManager().project_b_issue2) def test_create_issue_link_with_issue_objs(self): inwardissue = self.manager.jira_admin.issue( JiraTestManager().project_b_issue1) self.assertIsNotNone(inwardissue) outwardissue = self.manager.jira_admin.issue( JiraTestManager().project_b_issue2) self.assertIsNotNone(outwardissue) self.manager.jira_admin.create_issue_link(self.link_types[0].outward, inwardissue, outwardissue) # @unittest.skip("Creating an issue link doesn't return its ID, so can't easily test delete") # def test_delete_issue_link(self): # pass def test_issue_link_type(self): link_type = self.manager.jira_admin.issue_link_type(self.link_types[0].id) self.assertEqual(link_type.id, self.link_types[0].id) self.assertEqual(link_type.name, self.link_types[0].name) @flaky class MyPermissionsTests(unittest.TestCase): def setUp(self): self.test_manager = JiraTestManager() self.jira = JiraTestManager().jira_normal self.issue_1 = self.test_manager.project_b_issue1 def test_my_permissions(self): perms = self.jira.my_permissions() self.assertGreaterEqual(len(perms['permissions']), 40) def test_my_permissions_by_project(self): perms = self.jira.my_permissions(projectKey=self.test_manager.project_a) self.assertGreaterEqual(len(perms['permissions']), 10) perms = self.jira.my_permissions(projectId=self.test_manager.project_a_id) self.assertGreaterEqual(len(perms['permissions']), 10) @unittest.skip("broken") def test_my_permissions_by_issue(self): perms = self.jira.my_permissions(issueKey='ZTRAVISDEB-7') self.assertGreaterEqual(len(perms['permissions']), 10) perms = self.jira.my_permissions(issueId='11021') self.assertGreaterEqual(len(perms['permissions']), 10) @flaky class PrioritiesTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_priorities(self): priorities = self.jira.priorities() self.assertEqual(len(priorities), 5) @not_on_custom_jira_instance def test_priority(self): priority = self.jira.priority('2') self.assertEqual(priority.id, '2') self.assertEqual(priority.name, 'Critical') @flaky class ProjectTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin self.project_b = JiraTestManager().project_b self.test_manager = JiraTestManager() def test_projects(self): projects = self.jira.projects() self.assertGreaterEqual(len(projects), 2) def test_project(self): project = self.jira.project(self.project_b) self.assertEqual(project.key, self.project_b) # I have no idea what avatars['custom'] is and I get different results every time # def test_project_avatars(self): # avatars = self.jira.project_avatars(self.project_b) # self.assertEqual(len(avatars['custom']), 3) # self.assertEqual(len(avatars['system']), 16) # # def test_project_avatars_with_project_obj(self): # project = self.jira.project(self.project_b) # avatars = self.jira.project_avatars(project) # self.assertEqual(len(avatars['custom']), 3) # self.assertEqual(len(avatars['system']), 16) # def test_create_project_avatar(self): # Tests the end-to-end project avatar creation process: upload as temporary, confirm after cropping, # and selection. # project = self.jira.project(self.project_b) # size = os.path.getsize(TEST_ICON_PATH) # filename = os.path.basename(TEST_ICON_PATH) # with open(TEST_ICON_PATH, "rb") as icon: # props = self.jira.create_temp_project_avatar(project, filename, size, icon.read()) # self.assertIn('cropperOffsetX', props) # self.assertIn('cropperOffsetY', props) # self.assertIn('cropperWidth', props) # self.assertTrue(props['needsCropping']) # # props['needsCropping'] = False # avatar_props = self.jira.confirm_project_avatar(project, props) # self.assertIn('id', avatar_props) # # self.jira.set_project_avatar(self.project_b, avatar_props['id']) # # def test_delete_project_avatar(self): # size = os.path.getsize(TEST_ICON_PATH) # filename = os.path.basename(TEST_ICON_PATH) # with open(TEST_ICON_PATH, "rb") as icon: # props = self.jira.create_temp_project_avatar(self.project_b, filename, size, icon.read(), auto_confirm=True) # self.jira.delete_project_avatar(self.project_b, props['id']) # # def test_delete_project_avatar_with_project_obj(self): # project = self.jira.project(self.project_b) # size = os.path.getsize(TEST_ICON_PATH) # filename = os.path.basename(TEST_ICON_PATH) # with open(TEST_ICON_PATH, "rb") as icon: # props = self.jira.create_temp_project_avatar(project, filename, size, icon.read(), auto_confirm=True) # self.jira.delete_project_avatar(project, props['id']) # @pytest.mark.xfail(reason="Jira may return 500") # def test_set_project_avatar(self): # def find_selected_avatar(avatars): # for avatar in avatars['system']: # if avatar['isSelected']: # return avatar # else: # raise Exception # # self.jira.set_project_avatar(self.project_b, '10001') # avatars = self.jira.project_avatars(self.project_b) # self.assertEqual(find_selected_avatar(avatars)['id'], '10001') # # project = self.jira.project(self.project_b) # self.jira.set_project_avatar(project, '10208') # avatars = self.jira.project_avatars(project) # self.assertEqual(find_selected_avatar(avatars)['id'], '10208') def test_project_components(self): proj = self.jira.project(self.project_b) name = "component-%s from project %s" % (proj, rndstr()) component = self.jira.create_component(name, proj, description='test!!', assigneeType='COMPONENT_LEAD', isAssigneeTypeValid=False) components = self.jira.project_components(self.project_b) self.assertGreaterEqual(len(components), 1) sample = find_by_id(components, component.id) self.assertEqual(sample.id, component.id) self.assertEqual(sample.name, name) component.delete() def test_project_versions(self): name = "version-%s" % rndstr() version = self.jira.create_version(name, self.project_b, "will be deleted soon") versions = self.jira.project_versions(self.project_b) self.assertGreaterEqual(len(versions), 1) test = find_by_id(versions, version.id) self.assertEqual(test.id, version.id) self.assertEqual(test.name, name) i = self.jira.issue(JiraTestManager().project_b_issue1) i.update(fields={ 'versions': [{'id': version.id}], 'fixVersions': [{'id': version.id}]}) version.delete() def test_project_versions_with_project_obj(self): name = "version-%s" % rndstr() version = self.jira.create_version(name, self.project_b, "will be deleted soon") project = self.jira.project(self.project_b) versions = self.jira.project_versions(project) self.assertGreaterEqual(len(versions), 1) test = find_by_id(versions, version.id) self.assertEqual(test.id, version.id) self.assertEqual(test.name, name) version.delete() @unittest.skip("temporary disabled because roles() return a dictionary of role_name:role_url and we have no call to convert it to proper Role()") def test_project_roles(self): project = self.jira.project(self.project_b) role_name = 'Developers' dev = None for roles in [self.jira.project_roles(self.project_b), self.jira.project_roles(project)]: self.assertGreaterEqual(len(roles), 5) self.assertIn('Users', roles) self.assertIn(role_name, roles) dev = roles[role_name] self.assertTrue(dev) role = self.jira.project_role(self.project_b, dev.id) self.assertEqual(role.id, dev.id) self.assertEqual(role.name, dev.name) user = self.test_manager.jira_admin self.assertNotIn(user, role.actors) role.update(users=user, groups=['jira-developers', 'jira-users']) role = self.jira.project_role(self.project_b, dev.id) self.assertIn(user, role.actors) @not_on_custom_jira_instance @flaky class ResolutionTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_resolutions(self): resolutions = self.jira.resolutions() self.assertGreaterEqual(len(resolutions), 1) def test_resolution(self): resolution = self.jira.resolution('2') self.assertEqual(resolution.id, '2') self.assertEqual(resolution.name, 'Won\'t Fix') @flaky class SearchTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin self.project_b = JiraTestManager().project_b self.test_manager = JiraTestManager() self.issue = self.test_manager.project_b_issue1 def test_search_issues(self): issues = self.jira.search_issues('project=%s' % self.project_b) self.assertLessEqual(len(issues), 50) # default maxResults for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) def test_search_issues_maxresults(self): issues = self.jira.search_issues('project=%s' % self.project_b, maxResults=10) self.assertLessEqual(len(issues), 10) def test_search_issues_startat(self): issues = self.jira.search_issues('project=%s' % self.project_b, startAt=2, maxResults=10) self.assertGreaterEqual(len(issues), 1) # we know that project_b should have at least 3 issues def test_search_issues_field_limiting(self): issues = self.jira.search_issues('key=%s' % self.issue, fields='summary,comment') self.assertTrue(hasattr(issues[0].fields, 'summary')) self.assertTrue(hasattr(issues[0].fields, 'comment')) self.assertFalse(hasattr(issues[0].fields, 'reporter')) self.assertFalse(hasattr(issues[0].fields, 'progress')) def test_search_issues_expandos(self): issues = self.jira.search_issues('key=%s' % self.issue, expand='changelog') # self.assertTrue(hasattr(issues[0], 'names')) self.assertEqual(len(issues), 1) self.assertFalse(hasattr(issues[0], 'editmeta')) self.assertTrue(hasattr(issues[0], 'changelog')) self.assertEqual(issues[0].key, self.issue) @unittest.skip("Skipped due to https://jira.atlassian.com/browse/JRA-59619") @flaky class SecurityLevelTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_security_level(self): # This is hardcoded due to Atlassian bug: https://jira.atlassian.com/browse/JRA-59619 sec_level = self.jira.security_level('10000') self.assertEqual(sec_level.id, '10000') @flaky class ServerInfoTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_server_info(self): server_info = self.jira.server_info() self.assertIn('baseUrl', server_info) self.assertIn('version', server_info) @flaky class StatusTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_statuses(self): found = False statuses = self.jira.statuses() for status in statuses: if status.id == '10001' and status.name == 'Done': found = True break self.assertTrue(found, "Status Open with id=1 not found. [%s]" % statuses) self.assertGreater(len(statuses), 0) @flaky def test_status(self): status = self.jira.status('10001') self.assertEqual(status.id, '10001') self.assertEqual(status.name, 'Done') @flaky class UserTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin self.project_a = JiraTestManager().project_a self.project_b = JiraTestManager().project_b self.test_manager = JiraTestManager() self.issue = self.test_manager.project_b_issue3 def test_user(self): user = self.jira.user(self.test_manager.CI_JIRA_ADMIN) self.assertEqual(user.name, self.test_manager.CI_JIRA_ADMIN) self.assertRegex(user.emailAddress, '.*@example.com') @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_projects(self): users = self.jira.search_assignable_users_for_projects(self.test_manager.CI_JIRA_ADMIN, '%s,%s' % (self.project_a, self.project_b)) self.assertGreaterEqual(len(users), 1) usernames = map(lambda user: user.name, users) self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_projects_maxresults(self): users = self.jira.search_assignable_users_for_projects(self.test_manager.CI_JIRA_ADMIN, '%s,%s' % (self.project_a, self.project_b), maxResults=1) self.assertLessEqual(len(users), 1) @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_projects_startat(self): users = self.jira.search_assignable_users_for_projects(self.test_manager.CI_JIRA_ADMIN, '%s,%s' % (self.project_a, self.project_b), startAt=1) self.assertGreaterEqual(len(users), 0) @not_on_custom_jira_instance def test_search_assignable_users_for_issues_by_project(self): users = self.jira.search_assignable_users_for_issues(self.test_manager.CI_JIRA_ADMIN, project=self.project_b) self.assertEqual(len(users), 1) usernames = map(lambda user: user.name, users) self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_issues_by_project_maxresults(self): users = self.jira.search_assignable_users_for_issues(self.test_manager.CI_JIRA_USER, project=self.project_b, maxResults=1) self.assertLessEqual(len(users), 1) @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_issues_by_project_startat(self): users = self.jira.search_assignable_users_for_issues(self.test_manager.CI_JIRA_USER, project=self.project_a, startAt=1) self.assertGreaterEqual(len(users), 0) @not_on_custom_jira_instance def test_search_assignable_users_for_issues_by_issue(self): users = self.jira.search_assignable_users_for_issues(self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue) self.assertEqual(len(users), 1) usernames = map(lambda user: user.name, users) self.assertIn(self.test_manager.CI_JIRA_ADMIN, usernames) @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_issues_by_issue_maxresults(self): users = self.jira.search_assignable_users_for_issues(self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue, maxResults=2) self.assertLessEqual(len(users), 2) @pytest.mark.xfail(reason='query returns empty list') def test_search_assignable_users_for_issues_by_issue_startat(self): users = self.jira.search_assignable_users_for_issues(self.test_manager.CI_JIRA_ADMIN, issueKey=self.issue, startAt=2) self.assertGreaterEqual(len(users), 0) @pytest.mark.xfail(reason="Jira may return 500") def test_user_avatars(self): # Tests the end-to-end user avatar creation process: upload as temporary, confirm after cropping, # and selection. size = os.path.getsize(TEST_ICON_PATH) # filename = os.path.basename(TEST_ICON_PATH) with open(TEST_ICON_PATH, "rb") as icon: props = self.jira.create_temp_user_avatar(JiraTestManager().CI_JIRA_ADMIN, TEST_ICON_PATH, size, icon.read()) self.assertIn('cropperOffsetX', props) self.assertIn('cropperOffsetY', props) self.assertIn('cropperWidth', props) self.assertTrue(props['needsCropping']) props['needsCropping'] = False avatar_props = self.jira.confirm_user_avatar(JiraTestManager().CI_JIRA_ADMIN, props) self.assertIn('id', avatar_props) self.assertEqual(avatar_props['owner'], JiraTestManager().CI_JIRA_ADMIN) self.jira.set_user_avatar(JiraTestManager().CI_JIRA_ADMIN, avatar_props['id']) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) self.assertGreaterEqual(len(avatars['system']), 20) # observed values between 20-24 so far self.assertGreaterEqual(len(avatars['custom']), 1) @unittest.skip("broken: set avatar returns 400") def test_set_user_avatar(self): def find_selected_avatar(avatars): for avatar in avatars['system']: if avatar['isSelected']: return avatar # else: # raise Exception as e # print(e) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars['system'][0]) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) self.assertEqual(find_selected_avatar(avatars)['id'], avatars['system'][0]) self.jira.set_user_avatar(self.test_manager.CI_JIRA_ADMIN, avatars['system'][1]) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) self.assertEqual(find_selected_avatar(avatars)['id'], avatars['system'][1]) @unittest.skip("disable until I have permissions to write/modify") # WRONG def test_delete_user_avatar(self): size = os.path.getsize(TEST_ICON_PATH) filename = os.path.basename(TEST_ICON_PATH) with open(TEST_ICON_PATH, "rb") as icon: props = self.jira.create_temp_user_avatar(self.test_manager.CI_JIRA_ADMIN, filename, size, icon.read()) # print(props) self.jira.delete_user_avatar(self.test_manager.CI_JIRA_ADMIN, props['id']) def test_search_users(self): users = self.jira.search_users(self.test_manager.CI_JIRA_USER) self.assertGreaterEqual(len(users), 1) usernames = map(lambda user: user.name, users) self.assertIn(self.test_manager.CI_JIRA_USER, usernames) def test_search_users_maxresults(self): users = self.jira.search_users(self.test_manager.CI_JIRA_USER, maxResults=1) self.assertGreaterEqual(1, len(users)) @flaky def test_search_allowed_users_for_issue_by_project(self): users = self.jira.search_allowed_users_for_issue(self.test_manager.CI_JIRA_USER, projectKey=self.project_a) self.assertGreaterEqual(len(users), 1) @not_on_custom_jira_instance def test_search_allowed_users_for_issue_by_issue(self): users = self.jira.search_allowed_users_for_issue('a', issueKey=self.issue) self.assertGreaterEqual(len(users), 1) @pytest.mark.xfail(reason='query returns empty list') def test_search_allowed_users_for_issue_maxresults(self): users = self.jira.search_allowed_users_for_issue('a', projectKey=self.project_b, maxResults=2) self.assertLessEqual(len(users), 2) @pytest.mark.xfail(reason='query returns empty list') def test_search_allowed_users_for_issue_startat(self): users = self.jira.search_allowed_users_for_issue('c', projectKey=self.project_b, startAt=1) self.assertGreaterEqual(len(users), 0) def test_add_users_to_set(self): users_set = set( [self.jira.user(self.test_manager.CI_JIRA_ADMIN), self.jira.user(self.test_manager.CI_JIRA_ADMIN)]) self.assertEqual(len(users_set), 1) @flaky class VersionTests(unittest.TestCase): def setUp(self): self.manager = JiraTestManager() self.jira = JiraTestManager().jira_admin self.project_b = JiraTestManager().project_b def test_create_version(self): name = 'new version ' + self.project_b desc = 'test version of ' + self.project_b release_date = '2015-03-11' version = self.jira.create_version(name, self.project_b, releaseDate=release_date, description=desc) self.assertEqual(version.name, name) self.assertEqual(version.description, desc) self.assertEqual(version.releaseDate, release_date) version.delete() @flaky def test_create_version_with_project_obj(self): project = self.jira.project(self.project_b) version = self.jira.create_version('new version 2', project, releaseDate='2015-03-11', description='test version!') self.assertEqual(version.name, 'new version 2') self.assertEqual(version.description, 'test version!') self.assertEqual(version.releaseDate, '2015-03-11') version.delete() @flaky def test_update_version(self): version = self.jira.create_version('new updated version 1', self.project_b, releaseDate='2015-03-11', description='new to be updated!') version.update(name='new updated version name 1', description='new updated!') self.assertEqual(version.name, 'new updated version name 1') self.assertEqual(version.description, 'new updated!') v = self.jira.version(version.id) self.assertEqual(v, version) self.assertEqual(v.id, version.id) version.delete() def test_delete_version(self): version_str = "test_delete_version:" + self.manager.jid version = self.jira.create_version(version_str, self.project_b, releaseDate='2015-03-11', description='not long for this world') version.delete() self.assertRaises(JIRAError, self.jira.version, version.id) # def test_version_expandos(self): # pass @flaky class OtherTests(unittest.TestCase): def test_session_invalid_login(self): try: JIRA('https://support.atlassian.com', basic_auth=("xxx", "xxx"), validate=True, logging=False) except Exception as e: self.assertIsInstance(e, JIRAError) # 20161010: jira cloud returns 500 assert e.status_code in (401, 500) str(JIRAError) # to see that this does not raise an exception return assert False @flaky class SessionTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_session(self): user = self.jira.session() self.assertIsNotNone(user.raw['session']) def test_session_with_no_logged_in_user_raises(self): anon_jira = JIRA('https://support.atlassian.com', logging=False) self.assertRaises(JIRAError, anon_jira.session) # @pytest.mark.skipif(platform.python_version() < '3', reason='Does not work with Python 2') # @not_on_custom_jira_instance # takes way too long def test_session_server_offline(self): try: JIRA('https://127.0.0.1:1', logging=False, max_retries=0) except Exception as e: self.assertIn(type(e), (JIRAError, requests.exceptions.ConnectionError, AttributeError), e) return self.assertTrue(False, "Instantiation of invalid JIRA instance succeeded.") @flaky class WebsudoTests(unittest.TestCase): def setUp(self): self.jira = JiraTestManager().jira_admin def test_kill_websudo(self): self.jira.kill_websudo() # def test_kill_websudo_without_login_raises(self): # self.assertRaises(ConnectionError, JIRA) @flaky class UserAdministrationTests(unittest.TestCase): def setUp(self): self.test_manager = JiraTestManager() self.jira = self.test_manager.jira_admin self.test_username = "test_%s" % self.test_manager.project_a self.test_email = "%s@example.com" % self.test_username self.test_password = rndpassword() self.test_groupname = 'testGroupFor_%s' % self.test_manager.project_a def test_add_and_remove_user(self): try: self.jira.delete_user(self.test_username) except JIRAError: # we ignore if it fails to delete from start because we don't know if it already existed pass result = self.jira.add_user( self.test_username, self.test_email, password=self.test_password) assert result, True try: # Make sure user exists before attempting test to delete. self.jira.add_user( self.test_username, self.test_email, password=self.test_password) except JIRAError: pass result = self.jira.delete_user(self.test_username) assert result, True x = -1 # avoiding a zombie due to Atlassian caching for i in range(10): x = self.jira.search_users(self.test_username) if len(x) == 0: break sleep(1) self.assertEqual( len(x), 0, "Found test user when it should have been deleted. Test Fails.") @flaky def test_add_group(self): try: self.jira.remove_group(self.test_groupname) except JIRAError: pass sleep(2) # avoid 500 errors like https://travis-ci.org/pycontribs/jira/jobs/176544578#L552 result = self.jira.add_group(self.test_groupname) assert result, True x = self.jira.groups(query=self.test_groupname) self.assertEqual(self.test_groupname, x[0], "Did not find expected group after trying to add" " it. Test Fails.") self.jira.remove_group(self.test_groupname) def test_remove_group(self): try: self.jira.add_group(self.test_groupname) sleep(1) # avoid 400: https://travis-ci.org/pycontribs/jira/jobs/176539521#L395 except JIRAError: pass result = self.jira.remove_group(self.test_groupname) assert result, True x = -1 for i in range(5): x = self.jira.groups(query=self.test_groupname) if x == 0: break sleep(1) self.assertEqual(len( x), 0, 'Found group with name when it should have been deleted. Test Fails.') @not_on_custom_jira_instance @pytest.mark.xfail(reason="query may return empty list: https://travis-ci.org/pycontribs/jira/jobs/191274505#L520") def test_add_user_to_group(self): try: self.jira.add_user( self.test_username, self.test_email, password=self.test_password) self.jira.add_group(self.test_groupname) # Just in case user is already there. self.jira.remove_user_from_group( self.test_username, self.test_groupname) except JIRAError: pass result = self.jira.add_user_to_group( self.test_username, self.test_groupname) assert result, True x = self.jira.group_members(self.test_groupname) self.assertIn(self.test_username, x.keys(), 'Username not returned in group member list. Test Fails.') self.assertIn('email', x[self.test_username]) self.assertIn('fullname', x[self.test_username]) self.assertIn('active', x[self.test_username]) self.jira.remove_group(self.test_groupname) self.jira.delete_user(self.test_username) def test_remove_user_from_group(self): try: self.jira.add_user( self.test_username, self.test_email, password=self.test_password) except JIRAError: pass try: self.jira.add_group(self.test_groupname) except JIRAError: pass try: self.jira.add_user_to_group( self.test_username, self.test_groupname) except JIRAError: pass result = self.jira.remove_user_from_group( self.test_username, self.test_groupname) assert result, True sleep(2) x = self.jira.group_members(self.test_groupname) self.assertNotIn(self.test_username, x.keys(), 'Username found in group when it should have been removed. ' 'Test Fails.') self.jira.remove_group(self.test_groupname) self.jira.delete_user(self.test_username) class JiraShellTests(unittest.TestCase): def test_jirashell_command_exists(self): result = os.system('jirashell --help') self.assertEqual(result, 0) if __name__ == '__main__': # when running tests we expect various errors and we don't want to display them by default logging.getLogger("requests").setLevel(logging.FATAL) logging.getLogger("urllib3").setLevel(logging.FATAL) logging.getLogger("jira").setLevel(logging.FATAL) # j = JIRA("https://issues.citrite.net") # print(j.session()) dirname = "test-reports-%s%s" % (sys.version_info[0], sys.version_info[1]) unittest.main() # pass jira-1.0.10/tox.ini000066400000000000000000000012561304741173700140440ustar00rootroot00000000000000[tox] minversion = 2.3.1 envlist = {py27,py34,py35,py36}-{win,linux,darwin},docs skip_missing_interpreters = true [testenv:docs] basepython=python changedir=docs deps= -rrequirements.txt -rrequirements-dev.txt commands= sphinx-build -W -b html -d {envtmpdir}/doctrees . {envtmpdir}/html [testenv] sitepackages=False platform = win: windows linux: linux darwin: darwin deps= -rrequirements.txt -rrequirements-dev.txt -rrequirements-opt.txt commands= python -m pip check python -m flake8 python -m pytest # removed -n4 due to fixture failure -n4 setenv = PYTHONPATH = passenv = CI_JIRA_* [travis:after] toxenv = py27