pax_global_header00006660000000000000000000000064144472602270014522gustar00rootroot0000000000000052 comment=93eb05fc94f4eb49ed31b1adc03d14a94c6bc83c jira-3.5.2/000077500000000000000000000000001444726022700124565ustar00rootroot00000000000000jira-3.5.2/.coveragerc000066400000000000000000000003441444726022700146000ustar00rootroot00000000000000[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-3.5.2/.devcontainer/000077500000000000000000000000001444726022700152155ustar00rootroot00000000000000jira-3.5.2/.devcontainer/Dockerfile000066400000000000000000000010131444726022700172020ustar00rootroot00000000000000# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.231.5/containers/ubuntu/.devcontainer/base.Dockerfile # [Choice] Ubuntu version (use hirsuite or bionic on local arm64/Apple Silicon): hirsute, focal, bionic ARG VARIANT="hirsute" FROM mcr.microsoft.com/vscode/devcontainers/base:0-${VARIANT} # [Optional] Uncomment this section to install additional OS packages. # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ # && apt-get -y install --no-install-recommends \ jira-3.5.2/.devcontainer/devcontainer.json000066400000000000000000000046741444726022700206040ustar00rootroot00000000000000// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: // https://github.com/microsoft/vscode-dev-containers/tree/v0.231.5/containers/ubuntu { "name": "Ubuntu", "build": { "dockerfile": "Dockerfile", // Update 'VARIANT' to pick an Ubuntu version: hirsute, focal, bionic // Use hirsute or bionic on local arm64/Apple Silicon. "args": { "VARIANT": "focal" } }, // Set *default* container specific settings.json values on container create. "settings": { "[python]": { "editor.codeActionsOnSave": { "source.organizeImports": true, "editor.formatOnSave": true, }, "editor.formatOnSave": true, // Required to actually format on save }, "editor.rulers": [ 80, // default color or as customized (with "editorRuler.foreground") { "column": 88, "color": "#ff000065" }, ], "python.defaultInterpreterPath": "/usr/local/python/bin/python", "python.formatting.provider": "black", "python.linting.mypyEnabled": true, "python.linting.mypyPath": "mypy", "python.linting.mypyArgs": [ "--follow-imports=silent", "--ignore-missing-imports", "--show-column-numbers", "--show-error-codes" ], "python.linting.ignorePatterns": [ "**/site-packages/", ".vscode/*.py", "**/.tox/", "*.pyi", "**/dist/" ], "search.exclude": { "**/build/lib/*": true, "**/dist/*": true, }, "python.testing.pytestArgs": [ "--no-cov", "-v", "tests/" ], "python.testing.pytestEnabled": true, "python.testing.pytestPath": "pytest", }, // Add the IDs of extensions you want installed when the container is created. "extensions": [ "njpwerner.autodocstring", "yzhang.markdown-all-in-one", "ms-python.python", "littlefoxteam.vscode-python-test-adapter", "ms-vscode-remote.remote-containers", "hbenl.vscode-test-explorer", "trond-snekvik.simple-rst", "wayou.vscode-todo-highlight", "ms-azuretools.vscode-docker" ], // Use 'forwardPorts' to make a list of ports inside the container available locally. "forwardPorts": [ // The jira server instance we run via docker is exposed on: 2990 ], // Use 'postCreateCommand' to run commands after the container is created. "postCreateCommand": "./.devcontainer/post_create.sh", // Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. // "remoteUser": "vscode", "features": { "docker-in-docker": "latest", "git": "latest", "python": "3.10" } } jira-3.5.2/.devcontainer/post_create.sh000077500000000000000000000026771444726022700201000ustar00rootroot00000000000000#!/bin/bash # This file is run from the .vscode folder WORKSPACE_FOLDER=/workspaces/jira # Start the Jira Server docker instance first so can be running while we initialise everything else # Need to ensure this --version matches what is in CI echo "Initiating jira server instance, use the docker extension to inspect the logs (takes around 10/15mins to startup)" docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone --version 8.17.1 echo "Once started up, Jira host port is forwarded and can be found on: localhost:2990/jira/" # For Windows uses that have cloned into Windows' partition, we do this so that # it doesn't show all the files as "changed" for having different line endings. # As we use pre-commit for managing our line endings we do this to tell git we don't care git config --global core.autocrlf input git add . # Install tox and pre-commit pipx install pre-commit pipx install tox # Sanity check that we can run pre-commit env pre-commit run mypy --all-files # Set the PIP_CONSTRAINT env variable PIP_CONSTRAINT=$WORKSPACE_FOLDER/constraints.txt if [ -f "$PIP_CONSTRAINT" ]; then echo "$PIP_CONSTRAINT found, use 'unset PIP_CONSTRAINT' if you want to remove the constraints." echo "export PIP_CONSTRAINT="$PIP_CONSTRAINT"" >> ~/.bashrc && source ~/.bashrc else echo "$PIP_CONSTRAINT was not found, dependencies are not controlled." fi # Install package in editable mode with test dependencies pip install -e .[test] jira-3.5.2/.github/000077500000000000000000000000001444726022700140165ustar00rootroot00000000000000jira-3.5.2/.github/FUNDING.yml000066400000000000000000000001121444726022700156250ustar00rootroot00000000000000# These are supported funding model platforms github: [adehad, ssbarnea] jira-3.5.2/.github/ISSUE_TEMPLATE/000077500000000000000000000000001444726022700162015ustar00rootroot00000000000000jira-3.5.2/.github/ISSUE_TEMPLATE/bug_report.yml000066400000000000000000000052231444726022700210760ustar00rootroot00000000000000name: Bug report description: Create a report to help us improve body: - type: textarea id: summary attributes: label: Bug summary description: "A clear and concise description of what the bug is." validations: required: true - type: checkboxes id: exisitng-issue attributes: label: Is there an existing issue for this? description: Please search to see if an issue already exists for the bug you encountered. options: - label: I have searched the existing issues required: true - type: dropdown id: jira-instance-type attributes: label: Jira Instance type options: - Jira Cloud (Hosted by Atlassian) - Jira Server or Data Center (Self-hosted) validations: required: true - type: input id: jira-instance-version attributes: label: Jira instance version placeholder: "8.16.1" validations: required: false - type: input id: package-version attributes: label: jira-python version description: | The version(s) of the python package used, e.g. "main", "3.0.1". Be sure you have tried the latest release version, as that is only version supported. Testing on the latest "main" is also recommended before submitting a bug report. placeholder: main validations: required: true - type: input id: python-version attributes: label: Python Interpreter version description: The version(s) of Python used. placeholder: "3.8" validations: required: true - type: checkboxes id: operating-systems attributes: label: Which operating systems have you used? description: You may select more than one. options: - label: Linux - label: macOS - label: Windows - type: textarea id: repro attributes: label: Reproduction steps description: "(Python) Code example of how you trigger this bug. Please walk us through it step by step." value: | # 1. Given a Jira client instance jira: JIRA # 2. When I call the function with argument x jira.the_function(x) # 3. ... render: python validations: required: true - type: textarea id: stacktrace attributes: label: Stack trace description: "Any trace messages you can provide." render: python validations: required: true - type: textarea id: expected attributes: label: Expected behaviour description: "What you expected to happen." validations: required: true - type: textarea id: additional attributes: label: Additional Context description: "Any additional information or dependencies that can help diagnose the problem." placeholder: "ipython==7.16.1" validations: required: false jira-3.5.2/.github/ISSUE_TEMPLATE/feature_request.yml000066400000000000000000000014661444726022700221360ustar00rootroot00000000000000name: Feature request description: Suggest an idea for this project body: - type: textarea id: problem attributes: label: Problem trying to solve placeholder: "I'm always frustrated when [...]." validations: required: false - type: textarea id: solution attributes: label: Possible solution(s) description: "What you would want to happen." validations: required: false - type: textarea id: alternatives attributes: label: Alternatives description: "Workarounds, other solutions or features considered." validations: required: false - type: textarea id: additional attributes: label: Additional Context description: "Any additional information or screenshots that can help understand the context of the feature request." validations: required: false jira-3.5.2/.github/dependabot.yml000066400000000000000000000006661444726022700166560ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: pip directory: "/" schedule: interval: monthly open-pull-requests-limit: 10 target-branch: main labels: - "dependencies" - "skip-changelog" - package-ecosystem: "github-actions" target-branch: main directory: "/" open-pull-requests-limit: 10 schedule: interval: "monthly" labels: - "dependencies" - "skip-changelog" jira-3.5.2/.github/release-drafter.yml000066400000000000000000000001311444726022700176010ustar00rootroot00000000000000# see https://github.com/ansible-community/devtools _extends: ansible-community/devtools jira-3.5.2/.github/stale.yml000066400000000000000000000040241444726022700156510ustar00rootroot00000000000000# Configuration for probot-stale - https://github.com/probot/stale # Number of days of inactivity before an Issue or Pull Request becomes stale daysUntilStale: 90 # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. daysUntilClose: 14 # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) onlyLabels: [] # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable exemptLabels: - pinned - security - "[Status] Maybe Later" # Set to true to ignore issues in a project (defaults to false) exemptProjects: false # Set to true to ignore issues in a milestone (defaults to false) exemptMilestones: false # Set to true to ignore issues with an assignee (defaults to false) exemptAssignees: false # Label to use when marking as stale staleLabel: stale # Comment to post when marking as stale. Set to `false` to disable markComment: > This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. You way want to consider using the Sponsor button in order to persuade someone to address it. # Comment to post when removing the stale label. # unmarkComment: > # Your comment here. # Comment to post when closing a stale Issue or Pull Request. # closeComment: > # Your comment here. # Limit the number of actions per hour, from 1-30. Default is 30 limitPerRun: 10 # Limit to only `issues` or `pulls` # only: pulls # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': pulls: daysUntilStale: 30 markComment: > This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions. # issues: # exemptLabels: # - confirmed jira-3.5.2/.github/workflows/000077500000000000000000000000001444726022700160535ustar00rootroot00000000000000jira-3.5.2/.github/workflows/ack.yml000066400000000000000000000004041444726022700173320ustar00rootroot00000000000000# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/ack.yml name: ack on: pull_request_target: types: [opened, labeled, unlabeled, synchronize] jobs: ack: uses: ansible-community/devtools/.github/workflows/ack.yml@main jira-3.5.2/.github/workflows/jira_ci.yml000066400000000000000000000017011444726022700201750ustar00rootroot00000000000000name: ci on: # Trigger the workflow on push or pull request, # but only for the main branch push: branches: - main pull_request: branches: - main jobs: server: uses: pycontribs/jira/.github/workflows/jira_server_ci.yml@main cloud: needs: server uses: pycontribs/jira/.github/workflows/jira_cloud_ci.yml@main secrets: CLOUD_ADMIN: ${{ secrets.CI_JIRA_CLOUD_ADMIN }} CLOUD_ADMIN_TOKEN: ${{ secrets.CI_JIRA_CLOUD_ADMIN_TOKEN }} CLOUD_USER: ${{ secrets.CI_JIRA_CLOUD_USER }} CLOUD_USER_TOKEN: ${{ secrets.CI_JIRA_CLOUD_USER_TOKEN }} # 'check' the only job that should be marked as required in # repository config, so we do not need to change required jobs # when we add new/remove/rename jobs. check: needs: - cloud runs-on: ubuntu-latest steps: - name: Report success of the test matrix run: >- print("All's good") shell: python jira-3.5.2/.github/workflows/jira_cloud_ci.yml000066400000000000000000000041031444726022700213620ustar00rootroot00000000000000name: cloud on: workflow_call: secrets: CLOUD_ADMIN: required: true CLOUD_ADMIN_TOKEN: required: true CLOUD_USER: required: true CLOUD_USER_TOKEN: required: true workflow_dispatch: jobs: test: environment: cloud name: py${{ matrix.python-version }} runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] # We only test a single version to prevent concurrent # running of tests influencing one another python-version: ["3.8"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Setup the Pip cache uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} key: >- ${{ runner.os }}-pip-${{ hashFiles('setup.cfg') }}-${{ hashFiles('setup.py') }}-${{ hashFiles('tox.ini') }}-${{ hashFiles('.pre-commit-config.yaml') }} restore-keys: | ${{ runner.os }}-pip- ${{ runner.os }}- - name: Install Dependencies run: | sudo apt-get update; sudo apt-get install gcc libkrb5-dev python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Test with tox run: tox -e py38 -- -m allow_on_cloud env: CI_JIRA_TYPE: CLOUD CI_JIRA_CLOUD_ADMIN: ${{ secrets.CLOUD_ADMIN }} CI_JIRA_CLOUD_ADMIN_TOKEN: ${{ secrets.CLOUD_ADMIN_TOKEN }} CI_JIRA_CLOUD_USER: ${{ secrets.CLOUD_USER }} CI_JIRA_CLOUD_USER_TOKEN: ${{ secrets.CLOUD_USER_TOKEN }} - name: Upload coverage to Codecov uses: codecov/codecov-action@v3.1.4 with: file: ./coverage.xml name: ${{ runner.os }}-${{ matrix.python-version }}-Cloud jira-3.5.2/.github/workflows/jira_server_ci.yml000066400000000000000000000037441444726022700215740ustar00rootroot00000000000000name: server on: workflow_call: workflow_dispatch: jobs: test: name: py${{ matrix.python-version }}-jira${{ matrix.jira-version }} runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest] python-version: ["3.8", "3.9", "3.10", "3.11"] jira-version: [8.17.1] steps: - uses: actions/checkout@v3 - name: Start Jira docker instance run: docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone --version ${{ matrix.jira-version }} - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Get pip cache dir id: pip-cache run: | echo "::set-output name=dir::$(pip cache dir)" - name: Setup the Pip cache uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} key: >- ${{ runner.os }}-pip-${{ hashFiles('setup.cfg') }}-${{ hashFiles('pyproject.toml') }}-${{ hashFiles('tox.ini') }}-${{ hashFiles('.pre-commit-config.yaml') }} restore-keys: | ${{ runner.os }}-pip- ${{ runner.os }}- - name: Install Dependencies run: | sudo apt-get update; sudo apt-get install gcc libkrb5-dev python -m pip install --upgrade pip python -m pip install --upgrade tox tox-gh-actions - name: Lint with tox if: ${{ 'Skipped as pre-commit GHA also running'== 'true' }} run: tox -e lint - name: Test with tox run: tox - name: Upload coverage to Codecov uses: codecov/codecov-action@v3.1.4 with: file: ./coverage.xml name: ${{ runner.os }}-${{ matrix.python-version }} - name: Run tox packaging run: tox -e packaging - name: Make docs if: ${{ 'Skipped as readthedocs GHA also running'== 'true' }} run: tox -e docs jira-3.5.2/.github/workflows/push.yml000066400000000000000000000004101444726022700175500ustar00rootroot00000000000000# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/push.yml name: push on: push: branches: - main - 'releases/**' - 'stable/**' jobs: ack: uses: ansible-community/devtools/.github/workflows/push.yml@main jira-3.5.2/.github/workflows/release.yml000066400000000000000000000023321444726022700202160ustar00rootroot00000000000000name: release on: release: types: [published] jobs: pypi: name: Publish to PyPI registry environment: release runs-on: ubuntu-20.04 env: FORCE_COLOR: 1 PY_COLORS: 1 TOXENV: packaging TOX_PARALLEL_NO_SPINNER: 1 steps: - name: Switch to using Python 3.8 by default uses: actions/setup-python@v4 with: python-version: 3.8 - name: Install build dependencies run: python3 -m pip install --user tox - name: Check out src from Git uses: actions/checkout@v3 with: fetch-depth: 0 # needed by setuptools-scm - name: Build dists run: python -m tox - name: Publish to test.pypi.org if: >- # "create" workflows run separately from "push" & "pull_request" github.event_name == 'release' uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.testpypi_password }} repository_url: https://test.pypi.org/legacy/ - name: Publish to pypi.org if: >- # "create" workflows run separately from "push" & "pull_request" github.event_name == 'release' uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.pypi_password }} jira-3.5.2/.gitignore000066400000000000000000000005761444726022700144560ustar00rootroot00000000000000.idea *.bak *.egg *.egg-info/ *.pyc .cache/ .coverage .coverage.* .eggs/ .tox/ amps-standalone* coverage.xml dist/ docs/_build docs/build encrypt-credentials.sh pip-wheel-metadata/ reports reports/ setenv.sh settings.py tests/settings.py tests/test-reports-*/* **/*.log /.python-version /CHANGELOG /ChangeLog /AUTHORS /tests/build /.pytest_cache /.vscode /node_modules .mypy_cache/ jira-3.5.2/.pre-commit-config.yaml000066400000000000000000000027611444726022700167450ustar00rootroot00000000000000--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace - id: mixed-line-ending - id: check-byte-order-marker - id: check-executables-have-shebangs - id: check-merge-conflict - id: check-symlinks - id: check-vcs-permalinks - id: debug-statements - id: check-yaml files: .*\.(yaml|yml)$ - repo: https://github.com/codespell-project/codespell rev: v2.2.4 hooks: - id: codespell name: codespell description: Checks for common misspellings in text files. entry: codespell language: python types: [text] args: [] require_serial: false additional_dependencies: [] - repo: https://github.com/charliermarsh/ruff-pre-commit rev: "v0.0.270" hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black # after ruff, as ruff output may need fixing rev: 23.3.0 hooks: - id: black language_version: python3 - repo: https://github.com/adrienverge/yamllint rev: v1.32.0 hooks: - id: yamllint files: \.(yaml|yml)$ - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.3.0 hooks: - id: mypy additional_dependencies: - types-requests - types-pkg_resources args: [--no-strict-optional, --ignore-missing-imports, --show-error-codes] jira-3.5.2/.pyup.yml000066400000000000000000000003041444726022700142510ustar00rootroot00000000000000# https://pyup.io/docs/bot/config/ update: insecure pin: False schedule: "every week" assignees: - ssbarnea - hdost search: False requirements: - setup.cfg: pin: False update: insecure jira-3.5.2/.readthedocs.yml000066400000000000000000000007641444726022700155530ustar00rootroot00000000000000--- version: 2 formats: all build: os: ubuntu-22.04 tools: python: "3.8" jobs: # Work-around to actually constrain dependencies # https://github.com/readthedocs/readthedocs.org/issues/7258#issuecomment-1094978683 post_install: - python -m pip install --upgrade --upgrade-strategy eager --no-cache-dir .[docs,cli] -c constraints.txt python: install: - method: pip path: . extra_requirements: - docs # to autodoc jirashell - cli jira-3.5.2/.vscode/000077500000000000000000000000001444726022700140175ustar00rootroot00000000000000jira-3.5.2/.vscode/extensions.json000066400000000000000000000002701444726022700171100ustar00rootroot00000000000000{ "recommendations": [ "ms-azuretools.vscode-docker", "ms-vscode-remote.remote-containers", "njpwerner.autodocstring", "charliermarsh.ruff" ] } jira-3.5.2/.yamllint000066400000000000000000000014331444726022700143110ustar00rootroot00000000000000--- extends: default rules: braces: { max-spaces-inside: 1, level: error } brackets: { max-spaces-inside: 1, level: error } colons: { max-spaces-after: -1, level: error } commas: { max-spaces-after: -1, level: error } comments: disable comments-indentation: disable document-start: disable empty-lines: { max: 3, level: error } hyphens: { level: error } indentation: indent-sequences: consistent # spaces: consistent ignore: | /jobs/DFG # TODO: slowly fix reduce ignore pattern while fixing the errors key-duplicates: enable line-length: max: 270 allow-non-breakable-words: true allow-non-breakable-inline-mappings: true new-line-at-end-of-file: disable new-lines: disable trailing-spaces: disable truthy: disable ignore: .tox jira-3.5.2/AUTHORS.rst000066400000000000000000000005601444726022700143360ustar00rootroot00000000000000If 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-3.5.2/LICENSE000066400000000000000000000024131444726022700134630ustar00rootroot00000000000000Copyright (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-3.5.2/MANIFEST.in000066400000000000000000000004041444726022700142120ustar00rootroot00000000000000include LICENSE README.rst # Include include jira/py.typed # Exclude what is in these folders prune tests prune .github # Exclude these files exclude package-lock.json exclude test-requirements.* recursive-exclude * *.py[co] recursive-exclude * __pycache__ jira-3.5.2/README.rst000066400000000000000000000132471444726022700141540ustar00rootroot00000000000000=================== 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/github/issues/pycontribs/jira.svg :target: https://github.com/pycontribs/jira/issues .. image:: https://img.shields.io/badge/irc-%23pycontribs-blue :target: irc:///#pycontribs ------------ .. image:: https://readthedocs.org/projects/jira/badge/?version=main :target: https://jira.readthedocs.io/ .. image:: https://codecov.io/gh/pycontribs/jira/branch/main/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 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? By default only the basic library dependencies are installed, so if you want to use the ``cli`` tool or other optional dependencies do perform a full installation using ``pip install jira[opt,cli,test]`` .. _virtualenv: https://virtualenv.pypa.io/ Usage ----- See the documentation_ for full details. .. _documentation: https://jira.readthedocs.org/ Development ----------- Development takes place on GitHub_ using the default repository branch. Each version is tagged. Setup ===== * Fork_ repo * Keep it sync_'ed while you are developing Automatic (VS Code) ``````````````````` .. image:: https://img.shields.io/static/v1?label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode :target: https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/pycontribs/jira :alt: Open in Remote - Containers Follow the instructions in the `contributing guide`_, which will describe how to use the dev container that will automatically setup a suitable environment. Manual `````` * Install pyenv_ to install a suitable python version. * Launch docker jira server - ``docker run -dit -p 2990:2990 --name jira addono/jira-software-standalone`` tox envs ```````` * Lint - ``tox -e lint`` * Run tests - ``tox`` * Build and publish with TWINE - ``tox -e publish`` .. _Fork: https://help.github.com/articles/fork-a-repo/ .. _sync: https://help.github.com/articles/syncing-a-fork/ .. _pyenv: https://amaral.northwestern.edu/resources/guides/pyenv-tutorial .. _pytest: https://docs.pytest.org/en/stable/usage.html#specifying-tests-selecting-tests .. _contributing guide: https://jira.readthedocs.io/contributing.html Jira REST API Reference Links ============================= When updating interactions with the Jira REST API please refer to the documentation below. We aim to support both Jira Cloud and Jira Server / Data Center. 1. `Jira Cloud`_ / `Jira Server`_ (main REST API reference) 2. `Jira Software Cloud`_ / `Jira Software Server`_ (former names include: Jira Agile, Greenhopper) 3. `Jira Service Desk Cloud`_ / `Jira Service Desk Server`_ .. _`Jira Cloud`: https://developer.atlassian.com/cloud/jira/platform/rest/v2/ .. _`Jira Server`: https://docs.atlassian.com/software/jira/docs/api/REST/latest/ .. _`Jira Software Cloud`: https://developer.atlassian.com/cloud/jira/software/rest/ .. _`Jira Software Server`: https://docs.atlassian.com/jira-software/REST/latest/ .. _`Jira Service Desk Cloud`: https://docs.atlassian.com/jira-servicedesk/REST/cloud/ .. _`Jira Service Desk Server`: https://docs.atlassian.com/jira-servicedesk/REST/server/ Credits ------- In addition 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 continuous integration testing. * JetBrains_ for providing us with free licenses of PyCharm_ * GitHub_ for hosting our continuous integration and our git repo * Navicat_ for providing us free licenses of their powerful database client GUI tools. .. _Atlassian: https://www.atlassian.com/ .. _Jira: https://pycontribs.atlassian.net .. _JetBrains: https://www.jetbrains.com/ .. _PyCharm: https://www.jetbrains.com/pycharm/ .. _GitHub: https://github.com/pycontribs/jira .. _Navicat: https://www.navicat.com/ .. image:: https://raw.githubusercontent.com/pycontribs/resources/main/logos/x32/logo-atlassian.png :target: https://www.atlassian.com/ .. image:: https://raw.githubusercontent.com/pycontribs/resources/main/logos/x32/logo-pycharm.png :target: https://www.jetbrains.com/ .. image:: https://raw.githubusercontent.com/pycontribs/resources/main/logos/x32/logo-navicat.png :target: https://www.navicat.com/ jira-3.5.2/build/000077500000000000000000000000001444726022700135555ustar00rootroot00000000000000jira-3.5.2/build/.gitignore000066400000000000000000000001071444726022700155430ustar00rootroot00000000000000# Ignore everything in this directory * # Except this file !.gitignore jira-3.5.2/codecov.yml000066400000000000000000000002211444726022700146160ustar00rootroot00000000000000--- comment: off coverage: status: project: default: target: auto threshold: 0.50 base: auto patch: off jira-3.5.2/constraints.txt000066400000000000000000000107761444726022700156010ustar00rootroot00000000000000# # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --extra=async --extra=cli --extra=docs --extra=opt --extra=test --output-file=constraints.txt --strip-extras setup.cfg # alabaster==0.7.13 # via sphinx asttokens==2.2.1 # via stack-data attrs==22.2.0 # via pytest babel==2.12.1 # via sphinx backcall==0.2.0 # via ipython beautifulsoup4==4.12.2 # via furo certifi==2022.12.7 # via requests cffi==1.15.1 # via cryptography charset-normalizer==3.1.0 # via requests colorama==0.4.6 # via # ipython # pytest # sphinx coverage==7.2.7 # via pytest-cov cryptography==39.0.1 # via # pyspnego # requests-kerberos decorator==5.1.1 # via ipython defusedxml==0.7.1 # via jira (setup.cfg) docutils==0.19 # via # jira (setup.cfg) # sphinx exceptiongroup==1.1.1 # via pytest execnet==1.9.0 # via # pytest-cache # pytest-xdist executing==1.2.0 # via stack-data filemagic==1.6 # via jira (setup.cfg) flaky==3.7.0 # via jira (setup.cfg) furo==2022.12.7 # via jira (setup.cfg) idna==3.4 # via requests imagesize==1.4.1 # via sphinx importlib-metadata==6.0.0 # via # keyring # sphinx iniconfig==2.0.0 # via pytest ipython==8.9.0 # via jira (setup.cfg) jaraco-classes==3.2.3 # via keyring jedi==0.18.2 # via ipython jinja2==3.1.2 # via sphinx keyring==23.13.1 # via jira (setup.cfg) markupsafe==2.1.2 # via # jinja2 # jira (setup.cfg) matplotlib-inline==0.1.6 # via ipython more-itertools==9.1.0 # via jaraco-classes oauthlib==3.2.2 # via # jira (setup.cfg) # requests-oauthlib packaging==23.0 # via # jira (setup.cfg) # pytest # pytest-sugar # sphinx parameterized==0.9.0 # via jira (setup.cfg) parso==0.8.3 # via jedi pickleshare==0.7.5 # via ipython pluggy==1.0.0 # via pytest prompt-toolkit==3.0.38 # via ipython pure-eval==0.2.2 # via stack-data pycparser==2.21 # via cffi pygments==2.14.0 # via # furo # ipython # sphinx pyjwt==2.6.0 # via # jira (setup.cfg) # requests-jwt pyspnego==0.8.0 # via requests-kerberos pytest==7.2.1 # via # jira (setup.cfg) # pytest-cache # pytest-cov # pytest-instafail # pytest-sugar # pytest-timeout # pytest-xdist pytest-cache==1.0 # via jira (setup.cfg) pytest-cov==4.0.0 # via jira (setup.cfg) pytest-instafail==0.4.2 # via jira (setup.cfg) pytest-sugar==0.9.6 # via jira (setup.cfg) pytest-timeout==2.1.0 # via jira (setup.cfg) pytest-xdist==3.2.1 # via jira (setup.cfg) pytz==2023.3 # via babel pywin32-ctypes==0.2.0 # via keyring pyyaml==6.0 # via jira (setup.cfg) requests==2.28.2 # via # jira (setup.cfg) # requests-futures # requests-jwt # requests-kerberos # requests-mock # requests-oauthlib # requests-toolbelt # requires-io # sphinx requests-futures==1.0.0 # via jira (setup.cfg) requests-jwt==0.6.0 # via jira (setup.cfg) requests-kerberos==0.14.0 # via jira (setup.cfg) requests-mock==1.10.0 # via jira (setup.cfg) requests-oauthlib==1.3.1 # via jira (setup.cfg) requests-toolbelt==0.10.1 # via jira (setup.cfg) requires-io==0.2.6 # via jira (setup.cfg) six==1.16.0 # via # asttokens # requests-mock snowballstemmer==2.2.0 # via sphinx soupsieve==2.4.1 # via beautifulsoup4 sphinx==6.1.3 # via # furo # jira (setup.cfg) # sphinx-basic-ng # sphinx-copybutton sphinx-basic-ng==1.0.0b1 # via furo sphinx-copybutton==0.5.2 # via jira (setup.cfg) sphinxcontrib-applehelp==1.0.4 # via sphinx sphinxcontrib-devhelp==1.0.2 # via sphinx sphinxcontrib-htmlhelp==2.0.1 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx stack-data==0.6.2 # via ipython tenacity==8.2.2 # via jira (setup.cfg) termcolor==2.3.0 # via pytest-sugar tomli==2.0.1 # via # coverage # pytest traitlets==5.8.1 # via # ipython # matplotlib-inline typing-extensions==4.5.0 # via jira (setup.cfg) urllib3==1.26.14 # via requests wcwidth==0.2.6 # via prompt-toolkit wheel==0.40.0 # via jira (setup.cfg) xmlrunner==1.7.7 # via jira (setup.cfg) yanc==0.3.3 # via jira (setup.cfg) zipp==3.12.0 # via importlib-metadata jira-3.5.2/docs/000077500000000000000000000000001444726022700134065ustar00rootroot00000000000000jira-3.5.2/docs/advanced.rst000066400000000000000000000040331444726022700157050ustar00rootroot00000000000000Advanced ******** 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-3.5.2/docs/api.rst000066400000000000000000000027641444726022700147220ustar00rootroot00000000000000API Documentation ***************** jira package ============ jira.client module ------------------ .. automodule:: jira.client :members: :undoc-members: :show-inheritance: jira.config module ------------------ .. automodule:: jira.config :members: :undoc-members: :show-inheritance: jira.exceptions module ---------------------- .. automodule:: jira.exceptions :members: :undoc-members: :show-inheritance: jira.jirashell module --------------------- .. automodule:: jira.jirashell :members: :undoc-members: :show-inheritance: jira.resilientsession module ---------------------------- .. automodule:: jira.resilientsession :members: :undoc-members: :show-inheritance: jira.resources module --------------------- .. autodata:: jira.client.ResourceType :annotation: = alias of TypeVar(‘ResourceType’, contravariant=True, bound=jira.resources.Resource) .. automodule:: jira.resources :members: :undoc-members: :show-inheritance: :private-members: .. autoclass:: jira.resources.StatusCategory :members: :undoc-members: :show-inheritance: .. autoclass:: jira.resources.AgileResource :members: :undoc-members: :show-inheritance: .. autoclass:: jira.resources.Sprint :members: :undoc-members: :show-inheritance: .. autoclass:: jira.resources.Board :members: :undoc-members: :show-inheritance: jira.utils module ----------------- .. automodule:: jira.utils :members: :undoc-members: :show-inheritance: jira-3.5.2/docs/conf.py000066400000000000000000000227761444726022700147230ustar00rootroot00000000000000# # 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. # 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. from __future__ import annotations import jira as py_pkg # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. needs_sphinx = "4.0.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.intersphinx", "sphinx.ext.napoleon", "sphinx.ext.viewcode", # External "sphinx_copybutton", ] intersphinx_mapping = { "python": ("https://docs.python.org/3.8", None), "requests": ("https://requests.readthedocs.io/en/latest/", None), "requests-oauthlib": ("https://requests-oauthlib.readthedocs.io/en/latest/", None), "ipython": ("https://ipython.readthedocs.io/en/stable/", None), "pip": ("https://pip.pypa.io/en/stable/", None), } autodoc_default_options = { "member-order": "bysource", "members": True, "show-inheritance": True, "special-members": "__init__", "undoc-members": True, } autodoc_inherit_docstrings = False nitpick_ignore = [ ("py:class", "JIRA"), # in jira.resources we only import this class if type ("py:class", "jira.resources.AnyLike"), # Dummy subclass for type checking ("py:meth", "__recoverable"), # ResilientSession, not autogenerated # From other packages ("py:mod", "filemagic"), ("py:mod", "ipython"), ("py:mod", "pip"), ("py:class", "_io.BufferedReader"), ("py:class", "BufferedReader"), ("py:class", "Request"), ("py:class", "requests.models.Response"), ("py:class", "requests.sessions.Session"), ("py:class", "requests.structures.CaseInsensitiveDict"), ("py:class", "Response"), ("py:mod", "requests-kerberos"), ("py:mod", "requests-oauthlib"), ("py:class", "typing_extensions.TypeGuard"), # Py38 not happy with this typehint ("py:class", "TypeGuard"), # Py38 not happy with 'TypeGuard' in docstring ] # Add any paths that contain templates here, relative to this directory. # templates_path = [] # 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 = py_pkg.__name__ copyright = "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 = py_pkg.__version__ # The full version, including alpha/beta/rc tags. release = py_pkg.__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 = "1" # 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 = "furo" # 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. # https://pradyunsg.me/furo/customisation/ # html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. # 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 = [] # html_style = "" # 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 = False # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. html_show_copyright = False # 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", "jira-python Documentation", "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", "jira-python Documentation", ["Atlassian Pty Ltd."], 1) ] # If true, show URL addresses after external links. # man_show_urls = False # -- Options for Napoleon ----------------------------------------------------- napoleon_google_docstring = True napoleon_numpy_docstring = False # Explicitly prefer Google style docstring napoleon_use_param = True # for type hint support napoleon_use_rtype = False # False so the return type is inline with the description. # -- 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", "jira-python Documentation", "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-3.5.2/docs/contributing.rst000066400000000000000000000125341444726022700166540ustar00rootroot00000000000000************ Contributing ************ 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 the GitHub 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://community.atlassian.com/t5/tag/jira-python/tg-p Keep in mind to use the jira-python tag when you add a new question. This will ensure that the project maintainers will get notified about your question. Contributing Code ***************** * Patches should be: * concise * work across all supported versions of Python. * follows the existing style of the code base (PEP-8). * included comments as required. * Great Patch has: * A test case that demonstrates the previous flaw that now passes with the included patch. * Documentation for those changes to a public API Testing ******* Dev Container +++++++++++++ We utilise Docker in order to generate a test Jira Server instance. This can be run manually, or automated using VS Code Dev Containers: #. Open the folder of the repository with VS Code #. Ensure you have Docker running #. Ensure you have the ``ms-azuretools.vscode-docker`` and ``ms-vscode-remote.remote-containers`` extensions installed. #. You should be able to do ``View >> Command Palette`` (or equivalent) and search for: ``Remote-containers: Rebuild and Reopen in container``. This will use the ``.devcontainer\Dockerfile`` as a base image with configurations dictated by ``.devcontainer\devcontainer.json``. .. TIP:: The Docker extension can be used to monitor the progress of the Jira server build, it takes a while! The tests will only run once the server is up and reachable on: http://localhost:2990/jira Running Tests +++++++++++++ Using tox .. code-block:: bash python -m pip install pipx pipx install tox tox * Lint - ``tox -e lint`` * Run tests - ``tox`` * Run tests for one env only - ``tox -e py38`` * Specify what tests to run with pytest_ - ``tox -e py39 -- tests/resources/test_attachment.py`` - ``tox -e py38 -- -m allow_on_cloud`` (Run only the cloud tests) * Debug tests with breakpoints by disabling the coverage plugin, with the ``--no-cov`` argument. - Example for VSCode on Windows : .. code-block:: java { "name": "Pytest", "type": "python", "request": "launch", "python": ".tox\\py39\\Scripts\\python.exe", "module": "pytest", "env": { "CI_JIRA_URL": "http://localhost:2990/jira", "CI_JIRA_ADMIN": "admin", "CI_JIRA_ADMIN_PASSWORD": "admin", "CI_JIRA_USER": "jira_user", "CI_JIRA_USER_FULL_NAME": "Newly Created CI User", "CI_JIRA_USER_PASSWORD": "jira", "CI_JIRA_ISSUE": "Task", "PYTEST_TIMEOUT": "0", // Don't timeout }, "args": [ // "-v", "--no-cov", // running coverage affects breakpoints "tests/resources/test_attachment.py" ] } .. _pytest: https://docs.pytest.org/en/stable/usage.html#specifying-tests-selecting-tests Issues and Feature Requests *************************** * Check to see if there's an existing issue/pull request for the bug/feature. All issues are at https://github.com/pycontribs/jira/issues and pull requests are at https://github.com/pycontribs/jira/pulls. * If there isn't an existing issue there, please file an issue. * An example template is provided for: * Bugs: https://github.com/pycontribs/jira/blob/main/.github/ISSUE_TEMPLATE/bug_report.yml * Features: https://github.com/pycontribs/jira/blob/main/.github/ISSUE_TEMPLATE/feature_request.yml * If possible, create a pull request with a (failing) test case demonstrating what's wrong. This makes the process for fixing bugs quicker & gets issues resolved sooner. Issues ****** Here are the best ways to help with open issues: * For issues without reproduction steps * Try to reproduce the issue, comment with the minimal amount of steps to reproduce the bug (a code snippet would be ideal). * If there is not a set of steps that can be made to reproduce the issue, at least make sure there are debug logs that capture the unexpected behavior. * Submit pull requests for open issues. Pull Requests ************* There are some key points that are needed to be met before a pull request can be merged: * All tests must pass for all python versions. (Once the Test Framework is fixed) * For now, no new failures should occur * All pull requests require tests that either test the new feature or test that the specific bug is fixed. Pull requests for minor things like adding a new region or fixing a typo do not need tests. * Must follow PEP8 conventions. * Within a major version changes must be backwards compatible. The best way to help with pull requests is to comment on pull requests by noting if any of these key points are missing, it will both help get feedback sooner to the issuer of the pull request and make it easier to determine for an individual with write permissions to the repository if a pull request is ready to be merged. jira-3.5.2/docs/examples.rst000066400000000000000000000403631444726022700157640ustar00rootroot00000000000000Examples ******** Here's a quick usage example: .. literalinclude:: ../examples/basic_use.py Another example with methods to authenticate with your Jira: .. literalinclude:: ../examples/auth.py This example shows how to work with Jira Agile / Jira Software (formerly GreenHopper): .. literalinclude:: ../examples/agile.py Quickstart ========== Initialization -------------- Everything goes through the :py:class:`jira.client.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:: jira = 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 :py:class:`jira.client.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. Cookie Based Authentication ^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. warning:: This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice `here `_. For Jira Cloud use the basic_auth= :ref:`basic-auth-api-token` authentication Pass a tuple of (username, password) to the ``auth`` constructor argument:: auth_jira = JIRA(auth=('username', 'password')) Using this method, authentication happens during the initialization of the object. If the authentication is successful, the retrieved session cookie will be used in future requests. Upon cookie expiration, authentication will happen again transparently. HTTP BASIC ^^^^^^^^^^ (username, password) """""""""""""""""""" .. warning:: This method of authentication is no longer supported on Jira Cloud. You can find the deprecation notice `here `_ For Jira Cloud use the basic_auth= :ref:`basic-auth-api-token` authentication. For Self Hosted Jira (Server, Data Center), consider the `Token Auth`_ authentication. Pass a tuple of (username, password) to the ``basic_auth`` constructor argument:: auth_jira = JIRA(basic_auth=('username', 'password')) .. _basic-auth-api-token: (username, api_token) """"""""""""""""""""" Or pass a tuple of (email, api_token) to the ``basic_auth`` constructor argument (JIRA Cloud):: auth_jira = JIRA(basic_auth=('email', 'API token')) .. seealso:: For Self Hosted Jira (Server, Data Center), refer to the `Token Auth`_ Section. 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': 'foo', 'access_token_secret': 'bar', 'consumer_key': 'jira-oauth-consumer', 'key_cert': key_cert_data } auth_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. * 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. Token Auth ^^^^^^^^^^ Jira Cloud """""""""" This is also referred to as an API Token in the `Jira Cloud documentation `_ :: auth_jira = JIRA(basic_auth=('email', 'API token')) Jira Self Hosted (incl. Jira Server/Data Center) """""""""""""""""""""""""""""""""""""""""""""""" This is also referred to as Personal Access Tokens (PATs) in the `Self-Hosted Documentation `_. The is available from Jira Core >= 8.14:: auth_jira = JIRA(token_auth='API token') Kerberos ^^^^^^^^ To enable Kerberos auth, set ``kerberos=True``:: auth_jira = JIRA(kerberos=True) To pass additional options to Kerberos auth use dict ``kerberos_options``, e.g.:: auth_jira = JIRA(kerberos=True, kerberos_options={'mutual_authentication': 'DISABLED'}) .. _jirashell-label: Headers ------- Headers can be provided to the internally used ``requests.Session``. If the user provides a header that the :py:class:`jira.client.JIRA` also attempts to set, the user provided header will take preference. For example if you want to use a custom User Agent:: from requests_toolbelt import user_agent jira = JIRA( basic_auth=("email", "API token"), options={"headers": {"User-Agent": user_agent("my_package", "0.0.1")}}, ) 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') If you want to unassign it again, just do:: jira.assign_issue(issue, None) 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}) Working with Rich Text ^^^^^^^^^^^^^^^^^^^^^^ You can use rich text in an issue's description or comment. In order to use rich text, the body content needs to be formatted using the Atlassian Document Format (ADF):: jira = JIRA(basic_auth=("email", "API token")) comment = { "type": "doc", "version": 1, "content": [ { "type": "codeBlock", "content": [ { "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque eget venenatis elit. Duis eu justo eget augue iaculis fermentum. Sed semper quam laoreet nisi egestas at posuere augue semper.", "type": "text" } ] } ] } jira.add_comment("AB-123", comment) Fields ------ Example for accessing the worklogs:: issue.fields.worklog.worklogs # list of Worklog objects issue.fields.worklog.worklogs[0].author issue.fields.worklog.worklogs[0].comment issue.fields.worklog.worklogs[0].created issue.fields.worklog.worklogs[0].id issue.fields.worklog.worklogs[0].self issue.fields.worklog.worklogs[0].started issue.fields.worklog.worklogs[0].timeSpent issue.fields.worklog.worklogs[0].timeSpentSeconds issue.fields.worklog.worklogs[0].updateAuthor # dictionary issue.fields.worklog.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:: # Search returns first 50 results, `maxResults` must be set to exceed this 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 for issue in jira.search_issues('reporter = currentUser() order by created desc', maxResults=3): print('{}: {}'.format(issue.key, issue.fields.summary)) Comments -------- Comments, like issues, are objects. Access 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 Obtain an individual comment if you know its ID:: comment = jira.comment('JRA-1330', '10234') Obtain comment author name and comment creation timestamp if you know its ID:: author = jira.comment('JRA-1330', '10234').author.displayName time = jira.comment('JRA-1330', '10234').created 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.update(body='updated comment body but no mail notification', notify=False) comment.delete() Get all images from a comment:: issue = jira.issue('JRA-1330') regex_for_png = re.compile(r'\!(\S+?\.(jpg|png|bmp))\|?\S*?\!') pngs_used_in_comment = regex_for_png.findall(issue.fields.comment.comments[0].body) for attachment in issue.fields.attachment: if attachment.filename in pngs_used_in_comment: with open(attachment.filename, 'wb') as f: f.write(attachment.get()) 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) # 'John Doe [ACME Inc.]' 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 the file itself that is going to be attachment. The file could be a file-like object or string, representing path on the local machine. You can also modify the 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`. from io import StringIO attachment = 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-3.5.2/docs/extra/000077500000000000000000000000001444726022700145315ustar00rootroot00000000000000jira-3.5.2/docs/extra/jira.xml000066400000000000000000000001641444726022700162010ustar00rootroot00000000000000 latest https://jira.readthedocs.io/en/latest/docset/jira.tgz jira-3.5.2/docs/index.rst000066400000000000000000000013601444726022700152470ustar00rootroot00000000000000Python 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 Jira Agile / Jira Software REST API also exists. Documentation is also available in `Dash `_ format. The source is stored at https://github.com/pycontribs/jira. The release history and notes and can be found at https://github.com/pycontribs/jira/releases Indices and tables ****************** * :ref:`genindex` * :ref:`modindex` * :ref:`search` jira-3.5.2/docs/installation.rst000066400000000000000000000037141444726022700166460ustar00rootroot00000000000000Installation ************ The easiest (and best) way to install jira-python is through `pip `_:: pip install jira This will handle installation of the client itself as well as the requirements. If you're going to run the client standalone, we strongly recommend using a `virtualenv `_: .. code-block:: bash python -m venv jira_python source jira_python/bin/activate pip install 'jira[cli]' or: .. code-block:: bash python -m venv jira_python jira_python/bin/pip install 'jira[cli]' 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 ============ Python >=3.8 is required. - :py:mod:`requests` - `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 1.3.0. - :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 avatar creation. Installing through :py:mod:`pip` takes care of these dependencies for you. jira-3.5.2/docs/jirashell.rst000066400000000000000000000066531444726022700161270ustar00rootroot00000000000000jirashell ********* There is no substitute for play. The only way to really know a service, API or package is to explore it, poke at it, and bang your elbows -- trial and error. A REST design is especially well-suited for active exploration, and the ``jirashell`` script (installed automatically when you use pip) is designed to help you do exactly that. .. code-block:: bash pip install jira[cli] Run it from the command line .. code-block:: bash jirashell -s https://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 .. code-block:: python 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 .. code-block:: python 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-3.5.2/examples/000077500000000000000000000000001444726022700142745ustar00rootroot00000000000000jira-3.5.2/examples/agile.py000066400000000000000000000012171444726022700157300ustar00rootroot00000000000000# This script shows how to use the client in anonymous mode # against jira.atlassian.com. from __future__ import annotations from jira.client 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). # Override this with the options parameter. jira = JIRA(server="https://jira.atlassian.com") # Get all boards viewable by anonymous users. boards = jira.boards() # Get the sprints in a specific board board_id = 441 print(f"JIRA board: {boards[0].name} ({board_id})") sprints = jira.sprints(board_id) jira-3.5.2/examples/auth.py000066400000000000000000000022771444726022700156170ustar00rootroot00000000000000"""Some simple authentication examples.""" from __future__ import annotations from collections import Counter from typing import cast from jira import JIRA from jira.client import ResultList from jira.resources import Issue # Some Authentication Methods jira = JIRA( basic_auth=("admin", "admin"), # a username/password tuple [Not recommended] # basic_auth=("email", "API token"), # Jira Cloud: a username/token tuple # token_auth="API token", # Self-Hosted Jira (e.g. Server): the PAT token # auth=("admin", "admin"), # a username/password tuple for cookie auth [Not recommended] ) # Who has authenticated myself = jira.myself() # Get the mutable application properties for this server (requires # jira-system-administrators permission) props = jira.application_properties() # Find all issues reported by the admin # Note: we cast() for mypy's benefit, as search_issues can also return the raw json ! # This is if the following argument is used: `json_result=True` issues = cast(ResultList[Issue], 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-3.5.2/examples/basic_use.py000066400000000000000000000032561444726022700166110ustar00rootroot00000000000000# This script shows how to use the client in anonymous mode # against jira.atlassian.com. from __future__ import annotations import re 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(server="https://jira.atlassian.com") # 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.key) ] # 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(fields={"labels": ["AAA", "BBB"]}) # Or modify the List of existing labels. The new label is unicode with no # spaces issue.fields.labels.append("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.id, issue2) jira-3.5.2/examples/maintenance.py000077500000000000000000000046541444726022700171440ustar00rootroot00000000000000#!/usr/bin/env python # # This script will cleanup your jira instance by removing all projects and # it is used to clean the CI/CD Jira server used for testing. from __future__ import annotations import json import logging import os from jira import JIRA, Issue, JIRAError, Project, Role # noqa logging.getLogger().setLevel(logging.DEBUG) logging.getLogger("requests").setLevel(logging.INFO) logging.getLogger("urllib3").setLevel(logging.INFO) logging.getLogger("jira").setLevel(logging.DEBUG) CI_JIRA_URL = os.environ["CI_JIRA_URL"] CI_JIRA_ADMIN = os.environ["CI_JIRA_ADMIN"] CI_JIRA_ADMIN_TOKEN = os.environ["CI_JIRA_ADMIN_TOKEN"] j = JIRA( CI_JIRA_URL, basic_auth=(CI_JIRA_ADMIN, CI_JIRA_ADMIN_TOKEN), logging=True, validate=True, async_=True, async_workers=20, ) logging.info("Running maintenance as %s", j.current_user()) for p in j.projects(): logging.info("Deleting project %s", p) try: j.delete_project(p) except Exception as e: logging.error(e) for s in j.permissionschemes(): if " for Project" in s["name"]: logging.info(f"Deleting permission scheme: {s['name']}") try: j.delete_permissionscheme(s["id"]) except JIRAError as e: logging.error(e.text) else: logging.info(f"Permission scheme: {s['name']}") for s in j.issuesecurityschemes(): if " for Project" in s["name"]: logging.info("Deleting issue security scheme: %s", s["name"]) j.delete_permissionscheme(s["id"]) else: logging.error(f"Issue security scheme: {s['name']}") for s in j.projectcategories(): # if ' for Project' in s['name']: # print("Deleting issue security scheme: %s" % s['name']) # # j.delete_permissionscheme(s['id']) # else: logging.info(f"Project category: {s['name']}") for s in j.avatars("project"): logging.info("Avatar project: %s", s) # disabled until Atlassian implements DELETE verb # for s in j.screens(): # if s['id'] >= 1000: # try: # logging.info("Deleting screen: %s" % s['name']) # j.delete_screen(s['id']) # except Exception as e: # logging.error(e) # else: # logging.error(s) for s in j.notificationschemes(): logging.info("NotificationScheme: %s", s) # TODO(ssbarnea): "Default Issue Security Scheme" for t in j.templates(): logging.info("ProjectTemplate: %s", json.dumps(t, indent=4, sort_keys=True)) jira-3.5.2/jira/000077500000000000000000000000001444726022700134035ustar00rootroot00000000000000jira-3.5.2/jira/__init__.py000066400000000000000000000011461444726022700155160ustar00rootroot00000000000000"""The root of JIRA package namespace.""" from __future__ import annotations try: import importlib.metadata __version__ = importlib.metadata.version("jira") except Exception: __version__ = "unknown" from jira.client import ( JIRA, Comment, Issue, Priority, Project, Role, User, Watchers, Worklog, ) from jira.config import get_jira from jira.exceptions import JIRAError __all__ = ( "Comment", "__version__", "Issue", "JIRA", "JIRAError", "Priority", "Project", "Role", "User", "Watchers", "Worklog", "get_jira", ) jira-3.5.2/jira/client.py000066400000000000000000005752761444726022700152610ustar00rootroot00000000000000"""Jira Client module. 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.readthedocs.io/en/latest/. """ from __future__ import annotations import calendar import copy import datetime import hashlib import imghdr import json import logging as _logging import mimetypes import os import re import sys import time import urllib import warnings from collections import OrderedDict from collections.abc import Iterable from functools import lru_cache, wraps from io import BufferedReader from numbers import Number from typing import ( Any, Callable, Generic, Iterator, List, Literal, SupportsIndex, TypeVar, no_type_check, overload, ) from urllib.parse import parse_qs, quote, urlparse import requests from packaging.version import parse as parse_version from requests import Response from requests.auth import AuthBase from requests.structures import CaseInsensitiveDict from requests.utils import get_netrc_auth from requests_toolbelt import MultipartEncoder from jira import __version__ from jira.exceptions import JIRAError from jira.resilientsession import PrepareRequestForRetry, ResilientSession from jira.resources import ( AgileResource, Attachment, Board, Comment, Component, Customer, CustomFieldOption, Dashboard, Filter, Group, Issue, IssueLink, IssueLinkType, IssueProperty, IssueSecurityLevelScheme, IssueType, IssueTypeScheme, NotificationScheme, PermissionScheme, Priority, PriorityScheme, Project, RemoteLink, RequestType, Resolution, Resource, Role, SecurityLevel, ServiceDesk, Sprint, Status, StatusCategory, User, Version, Votes, Watchers, WorkflowScheme, Worklog, ) from jira.utils import json_loads, threaded_requests try: from requests_jwt import JWTAuth except ImportError: pass LOG = _logging.getLogger("jira") LOG.addHandler(_logging.NullHandler()) def translate_resource_args(func: Callable): """Decorator that converts Issue and Project resources to their keys when used as arguments. Args: func (Callable): the function to decorate """ @wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: arg_list = [] for arg in args: if isinstance(arg, (Issue, Project)): arg_list.append(arg.key) elif isinstance(arg, IssueLinkType): arg_list.append(arg.name) else: arg_list.append(arg) result = func(*arg_list, **kwargs) return result return wrapper def _field_worker( fields: dict[str, Any] = None, **fieldargs: Any ) -> dict[str, dict[str, Any]] | dict[str, dict[str, str]]: if fields is not None: return {"fields": fields} return {"fields": fieldargs} ResourceType = TypeVar("ResourceType", contravariant=True, bound=Resource) class ResultList(list, Generic[ResourceType]): def __init__( self, iterable: Iterable = None, _startAt: int = 0, _maxResults: int = 0, _total: int | None = None, _isLast: bool | None = None, ) -> None: """Results List. Args: iterable (Iterable): [description]. Defaults to None. _startAt (int): Start page. Defaults to 0. _maxResults (int): Max results per page. Defaults to 0. _total (Optional[int]): Total results from query. Defaults to 0. _isLast (Optional[bool]): True to mark this page is the last page? (Default: ``None``). see `The official API docs `_ """ 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 if _total is not None else len(self) self.iterable: list[ResourceType] = list(iterable) if iterable else [] self.current = self.startAt def __next__(self) -> ResourceType: # type:ignore[misc] self.current += 1 if self.current > self.total: raise StopIteration else: return self.iterable[self.current - 1] def __iter__(self) -> Iterator[ResourceType]: return super().__iter__() # fmt: off # The mypy error we ignore is about returning a contravariant type. # As this class is a List of a generic 'Resource' class # this is the right way to specify that the output is the same as which # the class was initialized with. @overload def __getitem__(self, i: SupportsIndex) -> ResourceType: ... # type:ignore[misc] # noqa: E704 @overload def __getitem__(self, s: slice) -> list[ResourceType]: ... # type:ignore[misc] # noqa: E704 def __getitem__(self, slice_or_index): # noqa: E301,E261 return list.__getitem__(self, slice_or_index) # fmt: on class QshGenerator: def __init__(self, context_path): self.context_path = context_path def __call__(self, req): qsh = self._generate_qsh(req) return hashlib.sha256(qsh.encode("utf-8")).hexdigest() def _generate_qsh(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 ) # create canonical query string according to docs at: # https://developer.atlassian.com/cloud/jira/platform/understanding-jwt-for-connect-apps/#qsh params = parse_qs(parse_result.query, keep_blank_values=True) joined = { key: ",".join(self._sort_and_quote_values(params[key])) for key in params } query = "&".join(f"{key}={joined[key]}" for key in sorted(joined.keys())) qsh = f"{req.method.upper()}&{path}&{query}" return qsh def _sort_and_quote_values(self, values): ordered_values = sorted(values) return [quote(value, safe="~") for value in ordered_values] class JiraCookieAuth(AuthBase): """Jira Cookie Authentication. Allows using cookie authentication as described by `jira api docs `_ """ def __init__( self, session: ResilientSession, session_api_url: str, auth: tuple[str, str] ): """Cookie Based Authentication. Args: session (ResilientSession): The Session object to communicate with the API. session_api_url (str): The session api url to use. auth (Tuple[str, str]): The username, password tuple. """ self._session = session self._session_api_url = session_api_url # e.g ."/rest/auth/1/session" self.__auth = auth self._retry_counter_401 = 0 self._max_allowed_401_retries = 1 # 401 aren't recoverable with retries really @property def cookies(self): return self._session.cookies def _increment_401_retry_counter(self): self._retry_counter_401 += 1 def _reset_401_retry_counter(self): self._retry_counter_401 = 0 def __call__(self, request: requests.PreparedRequest): request.register_hook("response", self.handle_401) return request def init_session(self): """Initialise the Session object's cookies, so we can use the session cookie. Raises: HTTPError: if the post returns an erroring http response """ username, password = self.__auth authentication_data = {"username": username, "password": password} r = self._session.post( # this also goes through the handle_401() hook self._session_api_url, data=json.dumps(authentication_data) ) r.raise_for_status() def handle_401(self, response: requests.Response, **kwargs) -> requests.Response: """Refresh cookies if the session cookie has expired. Then retry the request. Args: response (requests.Response): the response with the possible 401 to handle Returns: requests.Response """ if ( response.status_code == 401 and self._retry_counter_401 < self._max_allowed_401_retries ): LOG.info("Trying to refresh the cookie auth session...") self._increment_401_retry_counter() self.init_session() response = self.process_original_request(response.request.copy()) self._reset_401_retry_counter() return response def process_original_request(self, original_request: requests.PreparedRequest): self.update_cookies(original_request) return self.send_request(original_request) def update_cookies(self, original_request: requests.PreparedRequest): # Cookie header needs first to be deleted for the header to be updated using the # prepare_cookies method. See request.PrepareRequest.prepare_cookies if "Cookie" in original_request.headers: del original_request.headers["Cookie"] original_request.prepare_cookies(self.cookies) def send_request(self, request: requests.PreparedRequest): return self._session.send(request) class TokenAuth(AuthBase): """Bearer Token Authentication.""" def __init__(self, token: str): # setup any auth-related data here self._token = token def __call__(self, r: requests.PreparedRequest): # modify and return the request r.headers["authorization"] = f"Bearer {self._token}" return r class JIRA: """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:`jira.resources.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. 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.atlassian.com")`` """ DEFAULT_OPTIONS = { "server": "http://localhost:2990/jira", "auth_url": "/rest/auth/1/session", "context_path": "/", "rest_path": "api", "rest_api_version": "2", "agile_rest_path": AgileResource.AGILE_BASE_REST_PATH, "agile_rest_api_version": "1.0", "verify": True, "resilient": True, "async": False, "async_workers": 5, "client_cert": None, "check_update": False, # amount of seconds to wait for loading a resource after updating it # used to avoid server side caching issues, used to be 4 seconds. "delay_reload": 0, "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", }, "default_batch_size": { Resource: 100, }, } 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 = AgileResource.AGILE_BASE_URL def __init__( self, server: str = None, options: dict[str, str | bool | Any] = None, basic_auth: tuple[str, str] | None = None, token_auth: str | None = None, oauth: dict[str, Any] = None, jwt: dict[str, Any] = None, kerberos=False, kerberos_options: dict[str, Any] = None, validate=False, get_server_info: bool = True, async_: bool = False, async_workers: int = 5, logging: bool = True, max_retries: int = 3, proxies: Any = None, timeout: None | float | tuple[float, float] | tuple[float, None] | None = None, auth: tuple[str, str] = None, default_batch_sizes: dict[type[Resource], int | None] | None = 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`` or ``token_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")`` Args: server (Optional[str]): The server address and context path to use. Defaults to ``http://localhost:2990/jira``. options (Optional[Dict[str, bool, Any]]): 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 ``agile``. * verify (Union[bool, str]) -- Verify SSL certs. (Default: ``True``). Or path to a CA_BUNDLE file or directory with certificates of trusted CAs, for the `requests` library to use. * client_cert (Union[str, Tuple[str,str]]) -- Path to file with both cert and key or a tuple of (cert,key), for the `requests` library to use for client side SSL. * check_update -- Check whether using the newest python-jira library version. * headers -- a dict to update the default headers the session uses for all API requests. basic_auth (Optional[Tuple[str, str]]): A tuple of username and password to use when establishing a session via HTTP BASIC authentication. token_auth (Optional[str]): A string containing the token necessary for (PAT) bearer token authorization. oauth (Optional[Any]): 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) * signature_method (Optional) -- The signature method to use with OAuth. Defaults to oauthlib.oauth1.SIGNATURE_HMAC_SHA1 kerberos (bool): True to enable Kerberos authentication. (Default: ``False``) kerberos_options (Optional[Dict[str,str]]): A dict of properties for Kerberos authentication. The following properties are possible: * mutual_authentication -- string DISABLED or OPTIONAL. Example kerberos_options structure: ``{'mutual_authentication': 'DISABLED'}`` jwt (Optional[Any]): 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}}`` validate (bool): True makes your credentials first to be validated. Remember that if you are accessing Jira as anonymous it will fail. (Default: ``False``). get_server_info (bool): True fetches server version info first to determine if some API calls are available. (Default: ``True``). async_ (bool): True enables async requests for those actions where we implemented it, like issue update() or delete(). (Default: ``False``). async_workers (int): Set the number of worker threads for async operations. timeout (Optional[Union[Union[float, int], Tuple[float, float]]]): Set a read/connect timeout for the underlying calls to Jira. Obviously this means that you cannot rely on the return code when this is enabled. max_retries (int): Sets the amount Retries for the HTTP sessions initiated by the client. (Default: ``3``) proxies (Optional[Any]): Sets the proxies for the HTTP session. auth (Optional[Tuple[str,str]]): Set a cookie auth token if this is required. logging (bool): True enables loglevel to info => else critical. (Default: ``True``) default_batch_sizes (Optional[Dict[Type[Resource], Optional[int]]]): Manually specify the batch-sizes for the paginated retrieval of different item types. `Resource` is used as a fallback for every item type not specified. If an item type is mapped to `None` no fallback occurs, instead the JIRA-backend will use its default batch-size. By default all Resources will be queried in batches of 100. E.g., setting this to ``{Issue: 500, Resource: None}`` will make :py:meth:`search_issues` query Issues in batches of 500, while every other item type's batch-size will be controlled by the backend. (Default: None) """ # 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(sys.version_info) if options is None: options = {} if server and isinstance(server, dict): warnings.warn( "Old API usage, use JIRA(url) or JIRA(options={'server': url}, when using dictionary always use named parameters.", DeprecationWarning, ) options = server server = "" if server: options["server"] = server if async_: options["async"] = async_ options["async_workers"] = async_workers LOG.setLevel(_logging.INFO if logging else _logging.CRITICAL) self.log = LOG self._options: dict[str, Any] = copy.deepcopy(JIRA.DEFAULT_OPTIONS) if default_batch_sizes: self._options["default_batch_size"].update(default_batch_sizes) if "headers" in options: headers = copy.copy(options["headers"]) del options["headers"] else: headers = {} self._options.update(options) self._options["headers"].update(headers) self._rank = None # Rip off trailing slash since all urls depend on that assert isinstance(self._options["server"], str) # to help mypy if self._options["server"].endswith("/"): self._options["server"] = self._options["server"][:-1] context_path = urlparse(self.server_url).path if len(context_path) > 0: self._options["context_path"] = context_path self._try_magic() assert isinstance(self._options["headers"], dict) # for mypy benefit # Create Session object and update with config options first self._session = ResilientSession(timeout=timeout) # Add the client authentication certificate to the request if configured self._add_client_cert_to_session() # Add the SSL Cert to the request if configured self._add_ssl_cert_verif_strategy_to_session() self._session.headers.update(self._options["headers"]) if "cookies" in self._options: self._session.cookies.update(self._options["cookies"]) self._session.max_retries = max_retries if proxies: self._session.proxies = proxies # Setup the Auth last, # so that if any handlers take a copy of the session obj it will be ready if oauth: self._create_oauth_session(oauth) elif basic_auth: self._create_http_basic_session(*basic_auth) elif jwt: self._create_jwt_session(jwt) elif token_auth: self._create_token_session(token_auth) elif kerberos: self._create_kerberos_session(kerberos_options=kerberos_options) elif auth: self._create_cookie_auth(auth) # always log in for cookie based auth, as we need a first request to be logged in validate = True self.auth = auth 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 auth or "anonymous" ) raise JIRAError(f"Can not log in with {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: self.log.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_cache_value: dict[str, str] = {} # access via self._fields_cache @property def _fields_cache(self) -> dict[str, str]: """Cached dictionary of {Field Name: Field ID}. Lazy loaded.""" if not self._fields_cache_value: self._update_fields_cache() return self._fields_cache_value def _update_fields_cache(self): """Update the cache used for `self._fields_cache`.""" self._fields_cache_value = {} for f in self.fields(): if "clauseNames" in f: for name in f["clauseNames"]: self._fields_cache_value[name] = f["id"] @property def server_url(self) -> str: """Return the server url. Returns: str """ return str(self._options["server"]) @property def _is_cloud(self) -> bool: """Return whether we are on a Cloud based Jira instance.""" return self.deploymentType in ("Cloud",) def _create_cookie_auth(self, auth: tuple[str, str]): warnings.warn( "Use OAuth or Token based authentication " + "instead of Cookie based Authentication.", DeprecationWarning, ) self._session.auth = JiraCookieAuth( session=self._session, session_api_url="{server}{auth_url}".format(**self._options), auth=auth, ) 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( f"You are running an outdated version of Jira Python {__version__}. Current version is {released_version}. Do not file any bugs against older versions." ) except requests.RequestException: pass except Exception as e: self.log.warning(e) def __del__(self): """Destructor for JIRA instance.""" self.close() def close(self): session = getattr(self, "_session", None) if session is not None: 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 self._session = None def _check_for_html_error(self, content: str): # Jira has the bad habit of returning errors in pages with 200 and embedding the # error in a huge webpage. if "" in content: self.log.warning("Got SecurityTokenMissing") raise JIRAError(f"SecurityTokenMissing: {content}") return True def _get_sprint_field_id(self): sprint_field_name = "Sprint" sprint_field_id = [ f["schema"]["customId"] for f in self.fields() if f["name"] == sprint_field_name ][0] return sprint_field_id def _fetch_pages( self, item_type: type[ResourceType], items_key: str | None, request_path: str, startAt: int = 0, maxResults: int = 50, params: dict[str, Any] = None, base: str = JIRA_BASE_URL, ) -> ResultList[ResourceType]: """Fetch from a paginated end point. Args: item_type (Type[Resource]): Type of single item. ResultList of such items will be returned. items_key (Optional[str]): Path to the items in JSON returned from server. Set it to None, if response is an array, and not a JSON object. request_path (str): path in request URL startAt (int): index of the first record to be fetched. (Default: ``0``) maxResults (int): Maximum number of items to return. If maxResults evaluates as False, it will try to get all items in batches. (Default:50) params (Dict[str, Any]): 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. base (str): base URL to use for the requests. Returns: ResultList """ async_workers = None async_class = None if self._options["async"]: try: from requests_futures.sessions import FuturesSession async_class = FuturesSession except ImportError: pass async_workers = self._options.get("async_workers") page_params = params.copy() if params else {} if startAt: page_params["startAt"] = startAt if maxResults: page_params["maxResults"] = maxResults elif batch_size := self._get_batch_size(item_type): page_params["maxResults"] = batch_size resource = self._get_json(request_path, params=page_params, base=base) next_items_page = self._get_items_from_page(item_type, items_key, resource) items = next_items_page if True: # isinstance(resource, dict): if isinstance(resource, dict): total = resource.get("total") total = int(total) if total is not None else 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", False) 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) if batch_size is not None and page_size < batch_size: self.log.warning( "'batch_size' set to %s, but only received %s items in batch. Falling back to %s.", batch_size, page_size, page_size, ) page_start = (startAt or start_at_from_response or 0) + page_size if ( async_class is not None and not is_last and (total is not None and len(items) < total) ): async_fetches = [] future_session = async_class( session=self._session, max_workers=async_workers ) for start_index in range(page_start, total, page_size): page_params = params.copy() if params else {} page_params["startAt"] = start_index page_params["maxResults"] = page_size url = self._get_url(request_path) r = future_session.get(url, params=page_params) async_fetches.append(r) for future in async_fetches: response = future.result() resource = json_loads(response) if resource: next_items_page = self._get_items_from_page( item_type, items_key, resource ) items.extend(next_items_page) while ( async_class is None and not is_last and (total is None or page_start < total) and len(next_items_page) == page_size ): page_params = ( params.copy() if params else {} ) # Hack necessary for mock-calls to not change page_params["startAt"] = page_start page_params["maxResults"] = page_size resource = self._get_json( request_path, params=page_params, base=base ) if resource: next_items_page = self._get_items_from_page( item_type, items_key, 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: # TODO: unreachable # it seems that search_users can return a list() containing a single user! return ResultList( [item_type(self._options, self._session, resource)], 0, 1, 1, True ) def _get_items_from_page( self, item_type: type[ResourceType], items_key: str | None, resource: dict[str, Any], ) -> list[ResourceType]: try: return [ # We need to ignore the type here, as 'Resource' is an option item_type(self._options, self._session, raw_issue_json) # type: ignore 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)) def _get_batch_size(self, item_type: type[ResourceType]) -> int | None: """Return the batch size for the given resource type from the options. Check if specified item-type has a mapped batch-size, else try to fallback to batch-size assigned to `Resource`, else fallback to Backend-determined batch-size. Returns: Optional[int]: The batch size to use. When the configured batch size is None, the batch size should be determined by the JIRA-Backend. """ batch_sizes: dict[type[Resource], int | None] = self._options[ "default_batch_size" ] try: item_type_batch_size = batch_sizes[item_type] except KeyError: # Cannot find Resource-key -> Fallback to letting JIRA-Backend determine batch-size (=None) item_type_batch_size = batch_sizes.get(Resource, None) return item_type_batch_size # Information about this client def client_info(self) -> str: """Get the server this client is connected to.""" return self.server_url # Universal resource loading def find( self, resource_format: str, ids: tuple[str, str] | int | str = "" ) -> Resource: """Find Resource object for any addressable resource on the server. This method is a universal resource locator for any REST-ful 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. Args: resource_format (str): the subpath to the resource string ids (Optional[Tuple]): values to substitute in the ``resource_format`` string Returns: Resource """ resource = Resource(resource_format, self._options, self._session) resource.find(ids) return resource @no_type_check # FIXME: This function fails type checking, probably a bug or two def async_do(self, size: int = 10): """Execute all asynchronous jobs and wait for them to finish. By default it will run on 10 threads. Args: size (int): number of threads to run on. """ if hasattr(self._session, "_async_jobs"): self.log.info( f"Executing asynchronous {len(self._session._async_jobs)} jobs found in queue by using {size} threads..." ) threaded_requests.map(self._session._async_jobs, size=size) # Application properties # non-resource def application_properties( self, key: str = None ) -> dict[str, str] | list[dict[str, str]]: """Return the mutable server application properties. Args: key (Optional[str]): the single property to return a value for Returns: Union[Dict[str, str], List[Dict[str, str]]] """ params = {} if key is not None: params["key"] = key return self._get_json("application-properties", params=params) def set_application_property(self, key: str, value: str): """Set the application property. Args: key (str): key of the property to set value (str): value to assign to the property """ url = self._get_latest_url("application-properties/" + key) payload = {"id": key, "value": value} return self._session.put(url, data=json.dumps(payload)) def applicationlinks(self, cached: bool = True) -> list: """List of application links. Returns: List[Dict]: json, or empty list """ self._applicationlinks: list[dict] # for mypy benefit # 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.server_url + "/rest/applinks/latest/listApplicationlinks" r = self._session.get(url) o = json_loads(r) if "list" in o and isinstance(o, dict): self._applicationlinks = o["list"] else: self._applicationlinks = [] return self._applicationlinks # Attachments def attachment(self, id: str) -> Attachment: """Get an attachment Resource from the server for the specified ID. Args: id (str): The Attachment ID Returns: Attachment """ return self._find_for_resource(Attachment, id) # non-resource def attachment_meta(self) -> dict[str, int]: """Get the attachment metadata. Return: Dict[str, int] """ return self._get_json("attachment/meta") @translate_resource_args def add_attachment( self, issue: str | int, attachment: str | BufferedReader, filename: str = None, ) -> Attachment: """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.) Args: issue (Union[str, int]): the issue to attach the attachment to attachment (Union[str,BufferedReader]): file-like object to attach to the issue, also works if it is a string with the filename. filename (str): optional name for the attached file. If omitted, the file object's ``name`` attribute is used. If you acquired the file-like object by any other method than ``open()``, make sure that a name is specified in one way or the other. Returns: Attachment """ close_attachment = False if isinstance(attachment, str): attachment_io = open(attachment, "rb") # type: ignore close_attachment = True else: attachment_io = attachment if isinstance(attachment, BufferedReader) and attachment.mode != "rb": self.log.warning( "%s was not opened in 'rb' mode, attaching file may fail." % attachment.name ) fname = filename if not fname and isinstance(attachment_io, BufferedReader): fname = os.path.basename(attachment_io.name) def generate_multipartencoded_request_args() -> ( tuple[MultipartEncoder, CaseInsensitiveDict] ): """Returns MultipartEncoder stream of attachment, and the header.""" attachment_io.seek(0) encoded_data = MultipartEncoder( fields={"file": (fname, attachment_io, "application/octet-stream")} ) request_headers = CaseInsensitiveDict( { "content-type": encoded_data.content_type, "X-Atlassian-Token": "no-check", } ) return encoded_data, request_headers class RetryableMultipartEncoder(PrepareRequestForRetry): def prepare( self, original_request_kwargs: CaseInsensitiveDict ) -> CaseInsensitiveDict: encoded_data, request_headers = generate_multipartencoded_request_args() original_request_kwargs["data"] = encoded_data original_request_kwargs["headers"] = request_headers return super().prepare(original_request_kwargs) url = self._get_url(f"issue/{issue}/attachments") try: encoded_data, request_headers = generate_multipartencoded_request_args() r = self._session.post( url, data=encoded_data, headers=request_headers, _prepare_retry_class=RetryableMultipartEncoder(), # type: ignore[call-arg] # ResilientSession handles ) finally: if close_attachment: attachment_io.close() js: dict[str, Any] | list[dict[str, Any]] = json_loads(r) if not js or not isinstance(js, Iterable): raise JIRAError(f"Unable to parse JSON: {js}. Failed to add attachment?") jira_attachment = Attachment( self._options, self._session, js[0] if isinstance(js, List) else js ) if jira_attachment.size == 0: raise JIRAError( "Added empty attachment?!: " + f"Response: {r}\nAttachment: {jira_attachment}" ) return jira_attachment def delete_attachment(self, id: str) -> Response: """Delete attachment by id. Args: id (str): ID of the attachment to delete Returns: Response """ url = self._get_url("attachment/" + str(id)) return self._session.delete(url) # Components def component(self, id: str): """Get a component Resource from the server. Args: id (str): ID of the component to get """ return self._find_for_resource(Component, id) @translate_resource_args def create_component( self, name: str, project: str, description=None, leadUserName=None, assigneeType=None, isAssigneeTypeValid=False, ) -> Component: """Create a component inside a project and return a Resource for it. Args: name (str): name of the component project (str): key of the project to create the component in description (str): a description of the component leadUserName (Optional[str]): the username of the user responsible for this component assigneeType (Optional[str]): see the ComponentBean.AssigneeType class for valid values isAssigneeTypeValid (bool): True specifies whether the assignee type is acceptable (Default: ``False``) Returns: Component """ 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: str): """Get the count of related issues for a component. Args: id (str): ID of the component to use """ data: dict[str, Any] = self._get_json( "component/" + str(id) + "/relatedIssueCounts" ) return data["issueCount"] def delete_component(self, id: str) -> Response: """Delete component by id. Args: id (str): ID of the component to use Returns: Response """ url = self._get_url("component/" + str(id)) return self._session.delete(url) # Custom field options def custom_field_option(self, id: str) -> CustomFieldOption: """Get a custom field option Resource from the server. Args: id (str): ID of the custom field to use Returns: CustomFieldOption """ return self._find_for_resource(CustomFieldOption, id) # Dashboards def dashboards( self, filter=None, startAt=0, maxResults=20 ) -> ResultList[Dashboard]: """Return a ResultList of Dashboard resources and a ``total`` count. Args: filter (Optional[str]): either "favourite" or "my", the type of dashboards to return startAt (int): index of the first dashboard to return (Default: ``0``) maxResults (int): maximum number of dashboards to return. If maxResults set to False, it will try to get all items in batches. (Default: ``20``) Returns: ResultList """ params = {} if filter is not None: params["filter"] = filter return self._fetch_pages( Dashboard, "dashboards", "dashboard", startAt, maxResults, params, ) def dashboard(self, id: str) -> Dashboard: """Get a dashboard Resource from the server. Args: id (str): ID of the dashboard to get. Returns: Dashboard """ return self._find_for_resource(Dashboard, id) # Fields # non-resource def fields(self) -> list[dict[str, Any]]: """Return a list of all issue fields. Returns: List[Dict[str, Any]] """ return self._get_json("field") # Filters def filter(self, id: str) -> Filter: """Get a filter Resource from the server. Args: id (str): ID of the filter to get. Returns: Filter """ return self._find_for_resource(Filter, id) def favourite_filters(self) -> list[Filter]: """Get a list of filter Resources which are the favourites of the currently authenticated user. Returns: List[Filter] """ r_json: list[dict[str, Any]] = 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: str = None, description: str = None, jql: str = None, favourite: bool = None, ) -> Filter: """Create a new filter and return a filter Resource for it. Args: name (str): name of the new filter description (str): Useful human-readable description of the new filter jql (str): query string that defines the filter favourite (Optional[bool]): True adds this filter to the current user's favorites (Default: ``None``) Returns: Filter """ data: dict[str, Any] = {} 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: dict[str, Any] = json_loads(r) return Filter(self._options, self._session, raw=raw_filter_json) def update_filter( self, filter_id, name: str = None, description: str = None, jql: str = None, favourite: bool = None, ): """Update a filter and return a filter Resource for it. Args: name (Optional[str]): name of the new filter description (Optional[str]): Useful human-readable description of the new filter jql (Optional[str]): query string that defines the filter favourite (Optional[bool]): True to add this filter to the current user's favorites (Default: ``None``) """ filter = self.filter(filter_id) data = {} data["name"] = name or filter.name if description or hasattr(filter, "description"): # Jira omits .description if created with =None ! data["description"] = description or filter.description data["jql"] = jql or filter.jql data["favourite"] = favourite or filter.favourite url = self._get_url(f"filter/{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 def group(self, id: str, expand: Any = None) -> Group: """Get a group Resource from the server. Args: id (str): ID of the group to get expand (Optional[Any]): Extra information to fetch inside each resource Returns: Group """ group = Group(self._options, self._session) params = {} if expand is not None: params["expand"] = expand group.find(id, params=params) return group # non-resource def groups( self, query: str | None = None, exclude: Any | None = None, maxResults: int = 9999, ) -> list[str]: """Return a list of groups matching the specified criteria. Args: query (Optional[str]): filter groups by name with this string exclude (Optional[Any]): filter out groups by name with this string maxResults (int): maximum results to return. (Default: ``9999``) Returns: List[str] """ params: dict[str, Any] = {} 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: str) -> OrderedDict: """Return a hash or users with their information. Requires Jira 6.0 or will raise NotImplemented. Args: group (str): Name of the group. """ 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": f"users[{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"]: # 'id' is likely available only in older JIRA Server, # it's not available on newer JIRA Server. # 'name' is not available in JIRA Cloud. hasId = user.get("id") is not None and user.get("id") != "" hasName = user.get("name") is not None and user.get("name") != "" result[ user["id"] if hasId else user.get("name") if hasName else user.get("accountId") ] = { "name": user.get("name"), "id": user.get("id"), "accountId": user.get("accountId"), "fullname": user.get("displayName"), "email": user.get("emailAddress", "hidden"), "active": user.get("active"), "timezone": user.get("timezone"), } return OrderedDict(sorted(result.items(), key=lambda t: t[0])) def add_group(self, groupname: str) -> bool: """Create a new group in Jira. Args: groupname (str): The name of the group you wish to create. Returns: bool: True if successful. """ url = self._get_latest_url("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: str) -> bool: """Delete a group from the Jira instance. Args: groupname (str): The group to be deleted from the Jira instance. Returns: bool: Returns True on success. """ # implementation based on https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 url = self._get_latest_url("group") x = {"groupname": groupname} self._session.delete(url, params=x) return True # Issues def issue( self, id: Issue | str, fields: str | None = None, expand: str | None = None, properties: str | None = None, ) -> Issue: """Get an issue Resource from the server. Args: id (Union[Issue, str]): ID or key of the issue to get fields (Optional[str]): comma-separated string of issue fields to include in the results expand (Optional[str]): extra information to fetch inside each resource properties (Optional[str]): extra properties to fetch inside each result Returns: Issue """ # 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 if properties is not None: params["properties"] = properties issue.find(id, params=params) return issue def create_issue( self, fields: dict[str, Any] | None = None, prefetch: bool = True, **fieldargs, ) -> Issue: """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' set of methods. Further examples are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue Args: fields (Optional[Dict[str, Any]]): a dict containing field names and the values to use. If present, all other keyword arguments will be ignored prefetch (bool): True reloads the created issue Resource so all of its data is present in the value returned (Default: ``True``) Returns: Issue """ data: dict[str, Any] = _field_worker(fields, **fieldargs) p = data["fields"]["project"] project_id = None if isinstance(p, (str, int)): project_id = self.project(str(p)).id data["fields"]["project"] = {"id": project_id} p = data["fields"]["issuetype"] if isinstance(p, int): data["fields"]["issuetype"] = {"id": p} elif isinstance(p, str): data["fields"]["issuetype"] = { "id": self.issue_type_by_name( str(p), project=str(project_id) if project_id else None ).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( status_code=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: list[dict[str, Any]], prefetch: bool = True ) -> list[dict[str, Any]]: """Bulk create new issues and return an issue Resource for each successfully created issue. See `create_issue` documentation for field information. Args: field_list (List[Dict[str, Any]]): 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. prefetch (bool): True reloads the created issue Resource so all of its data is present in the value returned (Default: ``True``) Returns: List[Dict[str, Any]] """ data: dict[str, list] = {"issueUpdates": []} for field_dict in field_list: issue_data: dict[str, Any] = _field_worker(field_dict) p = issue_data["fields"]["project"] project_id = None if isinstance(p, (str, int)): project_id = self.project(str(p)).id issue_data["fields"]["project"] = {"id": project_id} p = issue_data["fields"]["issuetype"] if isinstance(p, int): issue_data["fields"]["issuetype"] = {"id": p} elif isinstance(p, str): issue_data["fields"]["issuetype"] = { "id": self.issue_type_by_name( str(p), project=str(project_id) if project_id else None ).id } data["issueUpdates"].append(issue_data) url = self._get_url("issue/bulk") try: r = self._session.post(url, data=json.dumps(data)) raw_issue_json = json_loads(r) # Catching case where none of the issues has been created. # See https://github.com/pycontribs/jira/issues/350 except JIRAError as je: if je.status_code == 400 and je.response: raw_issue_json = json.loads(je.response.text) else: raise 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 supports_service_desk(self): """Returns if the Jira instance supports service desk. Returns: bool """ url = self.server_url + "/rest/servicedeskapi/info" headers = {"X-ExperimentalApi": "opt-in"} try: r = self._session.get(url, headers=headers) return r.status_code == 200 except JIRAError: return False def create_customer(self, email: str, displayName: str) -> Customer: """Create a new customer and return an issue Resource for it. Args: email (str): Customer Email displayName (str): Customer display name Returns: Customer """ url = self.server_url + "/rest/servicedeskapi/customer" headers = {"X-ExperimentalApi": "opt-in"} r = self._session.post( url, headers=headers, data=json.dumps({"email": email, "displayName": displayName}), ) raw_customer_json = json_loads(r) if r.status_code != 201: raise JIRAError(status_code=r.status_code, request=r) return Customer(self._options, self._session, raw=raw_customer_json) def service_desks(self) -> list[ServiceDesk]: """Get a list of ServiceDesk Resources from the server visible to the current authenticated user. Returns: List[ServiceDesk] """ url = self.server_url + "/rest/servicedeskapi/servicedesk" headers = {"X-ExperimentalApi": "opt-in"} r_json = json_loads(self._session.get(url, headers=headers)) projects = [ ServiceDesk(self._options, self._session, raw_project_json) for raw_project_json in r_json["values"] ] return projects def service_desk(self, id: str) -> ServiceDesk: """Get a Service Desk Resource from the server. Args: id (str): ID or key of the Service Desk to get Returns: ServiceDesk """ return self._find_for_resource(ServiceDesk, id) @no_type_check # FIXME: This function does not do what it wants to with fieldargs def create_customer_request( self, fields: dict[str, Any] = None, prefetch: bool = True, **fieldargs ) -> Issue: """Create a new customer request 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 issue types. Some issue screens have different requirements for fields in a new issue. This information is available through the 'createmeta' set of methods. Further examples are available here: https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+Example+-+Create+Issue Args: fields (Dict[str, Any]): a dict containing field names and the values to use. If present, all other keyword arguments will be ignored prefetch (bool): True reloads the created issue Resource so all of its data is present in the value returned (Default: ``True``) Returns: Issue """ data = fields p = data["serviceDeskId"] service_desk = None if isinstance(p, (str, int)): service_desk = self.service_desk(p) elif isinstance(p, ServiceDesk): service_desk = p data["serviceDeskId"] = service_desk.id p = data["requestTypeId"] if isinstance(p, int): data["requestTypeId"] = p elif isinstance(p, str): data["requestTypeId"] = self.request_type_by_name(service_desk, p).id url = self.server_url + "/rest/servicedeskapi/request" headers = {"X-ExperimentalApi": "opt-in"} r = self._session.post(url, headers=headers, data=json.dumps(data)) raw_issue_json = json_loads(r) if "issueKey" not in raw_issue_json: raise JIRAError(status_code=r.status_code, request=r) if prefetch: return self.issue(raw_issue_json["issueKey"]) else: return Issue(self._options, self._session, raw=raw_issue_json) def createmeta_issuetypes( self, projectIdOrKey: str | int, ) -> dict[str, Any]: """Get the issue types metadata for a given project, required to create issues. This API was introduced in JIRA Server / DC 8.4 as a replacement for the more general purpose API 'createmeta'. For details see: https://confluence.atlassian.com/jiracore/createmeta-rest-endpoint-to-be-removed-975040986.html Args: projectIdOrKey (Union[str, int]): id or key of the project for which to get the metadata. Returns: Dict[str, Any] """ if self._is_cloud or self._version < (8, 4, 0): raise JIRAError( f"Unsupported JIRA deployment type: {self.deploymentType} or version: {self._version}. " "Use 'createmeta' instead." ) return self._get_json(f"issue/createmeta/{projectIdOrKey}/issuetypes") def createmeta_fieldtypes( self, projectIdOrKey: str | int, issueTypeId: str | int, ) -> dict[str, Any]: """Get the field metadata for a given project and issue type, required to create issues. This API was introduced in JIRA Server / DC 8.4 as a replacement for the more general purpose API 'createmeta'. For details see: https://confluence.atlassian.com/jiracore/createmeta-rest-endpoint-to-be-removed-975040986.html Args: projectIdOrKey (Union[str, int]): id or key of the project for which to get the metadata. issueTypeId (Union[str, int]): id of the issue type for which to get the metadata. Returns: Dict[str, Any] """ if self._is_cloud or self._version < (8, 4, 0): raise JIRAError( f"Unsupported JIRA deployment type: {self.deploymentType} or version: {self._version}. " "Use 'createmeta' instead." ) return self._get_json( f"issue/createmeta/{projectIdOrKey}/issuetypes/{issueTypeId}" ) def createmeta( self, projectKeys: tuple[str, str] | str | None = None, projectIds: list | tuple[str, str] = [], issuetypeIds: list[str] | None = None, issuetypeNames: str | None = None, expand: str | None = None, ) -> dict[str, Any]: """Get the metadata required to create issues, optionally filtered by projects and issue types. Args: projectKeys (Optional[Union[Tuple[str, str], str]]): keys of the projects to filter the results with. Can be a single value or a comma-delimited string. May be combined with projectIds. projectIds (Union[List, Tuple[str, str]]): IDs of the projects to filter the results with. Can be a single value or a comma-delimited string. May be combined with projectKeys. issuetypeIds (Optional[List[str]]): 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. issuetypeNames (Optional[str]): 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. expand (Optional[str]): extra information to fetch inside each resource. Returns: Dict[str, Any] """ if not self._is_cloud: if self._version >= (9, 0, 0): raise JIRAError( f"Unsupported JIRA version: {self._version}. " "Use 'createmeta_issuetypes' and 'createmeta_fieldtypes' instead." ) elif self._version >= (8, 4, 0): warnings.warn( "This API have been deprecated in JIRA 8.4 and is removed in JIRA 9.0. " "Use 'createmeta_issuetypes' and 'createmeta_fieldtypes' instead.", DeprecationWarning, stacklevel=2, ) params: dict[str, Any] = {} if projectKeys is not None: params["projectKeys"] = projectKeys if projectIds is not None: if isinstance(projectIds, str): 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) def _get_user_identifier(self, user: User) -> str: """Get the unique identifier depending on the deployment type. - Cloud: 'accountId' - Self Hosted: 'name' (equivalent to username). Args: user (User): a User object Returns: str: the User's unique identifier. """ return user.accountId if self._is_cloud else user.name def _get_user_id(self, user: str | None) -> str | None: """Internal method for translating a user search (str) to an id. Return None and -1 unchanged. This function uses :py:meth:`JIRA.search_users` to find the user and then using :py:meth:`JIRA._get_user_identifier` extracts the relevant identifier property depending on whether the instance is a Cloud or self-hosted Instance. Args: user (Optional[str]): The search term used for finding a user. None, '-1' and -1 are equivalent to 'Unassigned'. Raises: JIRAError: If any error occurs. Returns: Optional[str]: The Jira user's identifier. Or "-1" and None unchanged. """ if user in (None, -1, "-1"): return user try: user_obj: User if self._is_cloud: users = self.search_users(query=user, maxResults=20) else: users = self.search_users(user=user, maxResults=20) if len(users) < 1: raise JIRAError(f"No matching user found for: '{user}'") matches = [] if len(users) > 1: matches = [u for u in users if self._get_user_identifier(u) == user] user_obj = matches[0] if matches else users[0] except Exception as e: raise JIRAError(str(e)) return self._get_user_identifier(user_obj) # non-resource @translate_resource_args def assign_issue(self, issue: int | str, assignee: str | None) -> bool: """Assign an issue to a user. Args: issue (Union[int, str]): the issue ID or key to assign assignee (str): the user to assign the issue to. None will set it to unassigned. -1 will set it to Automatic. Returns: bool """ url = self._get_latest_url(f"issue/{issue}/assignee") user_id = self._get_user_id(assignee) payload = {"accountId": user_id} if self._is_cloud else {"name": user_id} self._session.put(url, data=json.dumps(payload)) return True @translate_resource_args def comments(self, issue: int | str, expand: str | None = None) -> list[Comment]: """Get a list of comment Resources of the issue provided. Args: issue (Union[int, str]): the issue ID or key to get the comments from expand (Optional[str]): extra information to fetch for each comment such as renderedBody and properties. Returns: List[Comment] """ params = {} if expand is not None: params["expand"] = expand r_json = self._get_json(f"issue/{issue}/comment", params=params) 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: int | str, comment: str, expand: str | None = None ) -> Comment: """Get a comment Resource from the server for the specified ID. Args: issue (Union[int, str]): the issue ID or key to get the comment from comment (str): ID of the comment to get expand (Optional[str]): extra information to fetch for each comment such as renderedBody and properties. Returns: Comment """ return self._find_for_resource(Comment, (issue, comment), expand=expand) @translate_resource_args def add_comment( self, issue: str | int | Issue, body: str, visibility: dict[str, str] | None = None, is_internal: bool = False, ) -> Comment: """Add a comment from the current authenticated user on the specified issue and return a Resource for it. Args: issue (Union[str, int, jira.resources.Issue]): ID or key of the issue to add the comment to body (str): Text of the comment to add visibility (Optional[Dict[str, str]]): a dict containing two entries: "type" and "value". "type" is 'role' (or 'group' if the Jira server has configured comment visibility for groups) "value" is the name of the role (or group) to which viewing of this comment will be restricted. is_internal (bool): True marks the comment as 'Internal' in Jira Service Desk (Default: ``False``) Returns: Comment: the created comment """ data: dict[str, Any] = {"body": body} if is_internal: data["properties"] = [ {"key": "sd.public.comment", "value": {"internal": is_internal}} ] 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)) return Comment(self._options, self._session, raw=json_loads(r)) # non-resource @translate_resource_args def editmeta(self, issue: str | int): """Get the edit metadata for an issue. Args: issue (Union[str, int]): the issue to get metadata for Returns: Dict[str, Dict[str, Dict[str, Any]]] """ return self._get_json("issue/" + str(issue) + "/editmeta") @translate_resource_args def remote_links(self, issue: str | int) -> list[RemoteLink]: """Get a list of remote link Resources from an issue. Args: issue (Union[str, int]): the issue to get remote links from Returns: List[RemoteLink] """ 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: str | int, id: str) -> RemoteLink: """Get a remote link Resource from the server. Args: issue (Union[str, int]): the issue holding the remote link id (str): ID of the remote link Returns: RemoteLink """ 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: str, destination: Issue | dict[str, Any], globalId: str | None = None, application: dict[str, Any] | None = None, relationship: str | None = None, ) -> RemoteLink: """Add a remote link from an issue to an external application and returns a remote link Resource for it. ``destination`` 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 ``destination`` and the keyword arguments ``globalId``, ``application`` and ``relationship``, see https://developer.atlassian.com/display/JIRADEV/JIRA+REST+API+for+Remote+Issue+Links. Args: issue (str): the issue to add the remote link to destination (Union[Issue, Dict[str, Any]]): the link details to add (see the above link for details) globalId (Optional[str]): unique ID for the link (see the above link for details) application (Optional[Dict[str,Any]]): application information for the link (see the above link for details) relationship (Optional[str]): relationship description for the link (see the above link for details) Returns: RemoteLink: the added remote link """ try: applicationlinks: list[dict] = 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: ({}) {}".format(e.status_code, e.text), Warning, ) data: dict[str, Any] = {} if isinstance(destination, Issue) and destination.raw: data["object"] = {"title": str(destination), "url": destination.permalink()} for x in applicationlinks: if x["application"]["displayUrl"] == destination._options["server"]: data["globalId"] = "appId={}&issueId={}".format( 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 if isinstance(destination, Issue) and destination.raw: for x in applicationlinks: if x["application"]["displayUrl"] == self.server_url: data["globalId"] = "appId={}&issueId={}".format( x["application"]["id"], destination.raw["id"], # .raw only present on Issue ) 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: str, object: dict[str, Any]): """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. Args: issue (str): the issue to add the remote link to object (Dict[str,Any]): the dictionary used to create remotelink data Returns: RemoteLink """ 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: str | int | Issue, id: str | None = None, expand=None): """Get a list of the transitions available on the specified issue to the current user. Args: issue (Union[str, int, jira.resources.Issue]): ID or key of the issue to get the transitions from id (Optional[str]): if present, get only the transition matching this ID expand (Optional): extra information to fetch inside each transition Returns: Any: json of response """ 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: str | int | Issue, transition_name: str ) -> int | None: """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 Args: issue (Union[str, int, jira.resources.Issue]): ID or key of the issue to get the transitions from transition_name (str): name of transition we are looking for Returns: Optional[int]: returns the id is found None when it's not """ transitions_json = self.transitions(issue) id: int | None = 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: str | int | Issue, transition: str, fields: dict[str, Any] | None = None, comment: str | None = None, worklog: str | None = 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. Args: issue (Union[str, int, jira.resources.Issue]): ID or key of the issue to perform the transition on transition (str): ID or name of the transition to perform fields (Optional[Dict[str,Any]]): a dict containing field names and the values to use. comment (Optional[str]): String to add as comment to the issue when performing the transition. worklog (Optional[str]): String to add as time spent on the issue when performing the transition. **fieldargs: If present, all other keyword arguments will be ignored """ transitionId: int | None = 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(f"Invalid transition name. {transition}") data: dict[str, Any] = {"transition": {"id": transitionId}} update_dict: dict[str, Any] = {} if comment: update_dict["comment"] = [{"add": {"body": comment}}] if worklog: update_dict["worklog"] = [{"add": {"timeSpent": worklog}}] if comment or worklog: data["update"] = update_dict 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: self.log.error(f"{e}\n{r.text}") raise e return r_json @translate_resource_args def votes(self, issue: str | int) -> Votes: """Get a votes Resource from the server. Args: issue (Union[str, int]): ID or key of the issue to get the votes for Returns: Votes """ return self._find_for_resource(Votes, issue) @translate_resource_args def project_issue_security_level_scheme( self, project: str ) -> IssueSecurityLevelScheme: """Get a IssueSecurityLevelScheme Resource from the server. Args: project (str): ID or key of the project to get the IssueSecurityLevelScheme for Returns: IssueSecurityLevelScheme: The issue security level scheme """ return self._find_for_resource(IssueSecurityLevelScheme, project) @translate_resource_args def project_notification_scheme(self, project: str) -> NotificationScheme: """Get a NotificationScheme Resource from the server. Args: project (str): ID or key of the project to get the NotificationScheme for Returns: NotificationScheme: The notification scheme """ return self._find_for_resource(NotificationScheme, project) @translate_resource_args def project_permissionscheme(self, project: str) -> PermissionScheme: """Get a PermissionScheme Resource from the server. Args: project (str): ID or key of the project to get the permissionscheme for Returns: PermissionScheme: The permission scheme """ return self._find_for_resource(PermissionScheme, project) @translate_resource_args def project_priority_scheme(self, project: str) -> PriorityScheme: """Get a PriorityScheme Resource from the server. Args: project (str): ID or key of the project to get the PriorityScheme for Returns: PriorityScheme: The priority scheme """ return self._find_for_resource(PriorityScheme, project) @translate_resource_args def project_workflow_scheme(self, project: str) -> WorkflowScheme: """Get a WorkflowScheme Resource from the server. Args: project (str): ID or key of the project to get the WorkflowScheme for Returns: WorkflowScheme: The workflow scheme """ return self._find_for_resource(WorkflowScheme, project) @translate_resource_args def add_vote(self, issue: str | int) -> Response: """Register a vote for the current authenticated user on an issue. Args: issue (Union[str, int]): ID or key of the issue to vote on Returns: Response """ url = self._get_url("issue/" + str(issue) + "/votes") return self._session.post(url) @translate_resource_args def remove_vote(self, issue: str | int): """Remove the current authenticated user's vote from an issue. Args: issue (Union[str, int]): ID or key of the issue to remove vote on """ url = self._get_url("issue/" + str(issue) + "/votes") self._session.delete(url) @translate_resource_args def watchers(self, issue: str | int) -> Watchers: """Get a watchers Resource from the server for an issue. Args: issue (Union[str, int]): ID or key of the issue to get the watchers for Returns: Watchers """ return self._find_for_resource(Watchers, issue) @translate_resource_args def add_watcher(self, issue: str | int, watcher: str) -> Response: """Add a user to an issue's watchers list. Args: issue (Union[str, int]): ID or key of the issue affected watcher (str): name of the user to add to the watchers list Returns: Response """ url = self._get_url("issue/" + str(issue) + "/watchers") return self._session.post(url, data=json.dumps(watcher)) @translate_resource_args def remove_watcher(self, issue: str | int, watcher: str) -> Response: """Remove a user from an issue's watch list. Args: issue (Union[str, int]): ID or key of the issue affected watcher (str): name of the user to remove from the watchers list Returns: Response """ url = self._get_url("issue/" + str(issue) + "/watchers") # https://docs.atlassian.com/software/jira/docs/api/REST/8.13.6/#api/2/issue-removeWatcher user_id = self._get_user_id(watcher) payload = {"accountId": user_id} if self._is_cloud else {"username": user_id} result = self._session.delete(url, params=payload) return result @translate_resource_args def worklogs(self, issue: str | int) -> list[Worklog]: """Get a list of worklog Resources from the server for an issue. Args: issue (Union[str, int]): ID or key of the issue to get worklogs from Returns: List[Worklog] """ 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: str | int, id: str) -> Worklog: """Get a specific worklog Resource from the server. Args: issue (Union[str, int]): ID or key of the issue to get the worklog from id (str): ID of the worklog to get Returns: Worklog """ return self._find_for_resource(Worklog, (issue, id)) @translate_resource_args def add_worklog( self, issue: str | int, timeSpent: (str | None) = None, timeSpentSeconds: (str | None) = None, adjustEstimate: (str | None) = None, newEstimate: (str | None) = None, reduceBy: (str | None) = None, comment: (str | None) = None, started: (datetime.datetime | None) = None, user: (str | None) = None, ) -> Worklog: """Add a new worklog entry on an issue and return a Resource for it. Args: issue (Union[str, int]): the issue to add the worklog to timeSpent (Optional[str]): a worklog entry with this amount of time spent, e.g. "2d" timeSpentSeconds (Optional[str]): a worklog entry with this amount of time spent in seconds adjustEstimate (Optional[str]): 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). newEstimate (Optional[str]): the new value for the remaining estimate field. e.g. "2d" reduceBy (Optional[str]): the amount to reduce the remaining estimate by e.g. "2d" comment (Optional[str]): optional worklog comment started (Optional[datetime.datetime]): Moment when the work is logged, if not specified will default to now user (Optional[str]): the user ID or name to use for this worklog Returns: Worklog """ 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: dict[str, Any] = {} 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" if started.tzinfo is None: data["started"] = started.strftime("%Y-%m-%dT%H:%M:%S.000+0000") else: 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(f"issue/{issue}/worklog") r = self._session.post(url, params=params, data=json.dumps(data)) return Worklog(self._options, self._session, json_loads(r)) # Issue properties @translate_resource_args def issue_properties(self, issue: str) -> list[IssueProperty]: """Get a list of issue property Resource from the server for an issue. Args: issue (str): ID or key of the issue to get properties from Returns: List[IssueProperty] """ r_json = self._get_json(f"issue/{issue}/properties") properties = [self.issue_property(issue, key["key"]) for key in r_json["keys"]] return properties @translate_resource_args def issue_property(self, issue: str, key: str) -> IssueProperty: """Get a specific issue property Resource from the server. Args: issue (str): ID or key of the issue to get the property from key (str): Key of the property to get Returns: IssueProperty """ return self._find_for_resource(IssueProperty, (issue, key)) @translate_resource_args def add_issue_property(self, issue: str, key: str, data) -> Response: """Add or update a specific issue property Resource. Args: issue (str): ID or key of the issue to set the property to key (str): Key of the property to set data: The data to set for the property Returns: Response """ url = self._get_url(f"issue/{issue}/properties/{key}") return self._session.put(url, data=json.dumps(data)) # Issue links @translate_resource_args def create_issue_link( self, type: str | IssueLinkType, inwardIssue: str, outwardIssue: str, comment: dict[str, Any] | None = None, ) -> Response: """Create a link between two issues. Args: type (Union[str,IssueLinkType]): the type of link to create inwardIssue: the issue to link from outwardIssue: the issue to link to comment (Optional[Dict[str, Any]]): 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. Returns: Response """ # let's see if we have the right issue link 'type' and fix it if needed issue_link_types = self.issue_link_types() if type not in issue_link_types: for lt in issue_link_types: if lt.outward == type: # we are smart to figure it out what he meant 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: str): """Delete a link between two issues. Args: id (str): ID of the issue link to delete """ url = self._get_url("issueLink") + "/" + id return self._session.delete(url) def issue_link(self, id: str) -> IssueLink: """Get an issue link Resource from the server. Args: id (str): ID of the issue link to get Returns: IssueLink """ return self._find_for_resource(IssueLink, id) # Issue link types def issue_link_types(self, force: bool = False) -> list[IssueLinkType]: """Get a list of issue link type Resources from the server. Args: force (bool): True forces an update of the cached IssueLinkTypes. (Default: ``False``) Returns: List[IssueLinkType] """ if not hasattr(self, "self._cached_issue_link_types") or force: r_json = self._get_json("issueLinkType") self._cached_issue_link_types = [ IssueLinkType(self._options, self._session, raw_link_json) for raw_link_json in r_json["issueLinkTypes"] ] return self._cached_issue_link_types def issue_link_type(self, id: str) -> IssueLinkType: """Get an issue link type Resource from the server. Args: id (str): ID of the issue link type to get Returns: IssueLinkType """ return self._find_for_resource(IssueLinkType, id) # Issue types def issue_types(self) -> list[IssueType]: """Get a list of issue type Resources from the server. Returns: List[IssueType] """ 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: str) -> IssueType: """Get an issue type Resource from the server. Args: id (str): ID of the issue type to get Returns: IssueType """ return self._find_for_resource(IssueType, id) def issue_type_by_name(self, name: str, project: str | None = None) -> IssueType: """Get issue type by name. Args: name (str): Name of the issue type project (str): Key or ID of the project. If set, only issue types available for that project will be looked up. Returns: IssueType """ if project: issue_types = self.project(project, expand="issueTypes").issueTypes else: issue_types = self.issue_types() matching_issue_types = [it for it in issue_types if it.name == name] if len(matching_issue_types) == 1: return matching_issue_types[0] elif len(matching_issue_types) == 0: raise KeyError(f"Issue type '{name}' is unknown.") else: raise KeyError(f"Issue type '{name}' appears more than once.") def request_types(self, service_desk: ServiceDesk) -> list[RequestType]: """Returns request types supported by a service desk instance. Args: service_desk (ServiceDesk): The service desk instance. Returns: List[RequestType] """ if hasattr(service_desk, "id"): service_desk = service_desk.id url = ( self.server_url + f"/rest/servicedeskapi/servicedesk/{service_desk}/requesttype" ) headers = {"X-ExperimentalApi": "opt-in"} r_json = json_loads(self._session.get(url, headers=headers)) request_types = [ RequestType(self._options, self._session, raw_type_json) for raw_type_json in r_json["values"] ] return request_types def request_type_by_name(self, service_desk: ServiceDesk, name: str): request_types = self.request_types(service_desk) try: request_type = [rt for rt in request_types if rt.name == name][0] except IndexError: raise KeyError(f"Request type '{name}' is unknown.") return request_type # User permissions # non-resource def my_permissions( self, projectKey: str | None = None, projectId: str | None = None, issueKey: str | None = None, issueId: str | None = None, permissions: str | None = None, ) -> dict[str, dict[str, dict[str, str]]]: """Get a dict of all available permissions on the server. ``permissions`` is a comma-separated value list of permission keys that is required in Jira Cloud. For possible and allowable permission values, see https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-permission-schemes/#built-in-permissions Args: projectKey (Optional[str]): limit returned permissions to the specified project projectId (Optional[str]): limit returned permissions to the specified project issueKey (Optional[str]): limit returned permissions to the specified issue issueId (Optional[str]): limit returned permissions to the specified issue permissions (Optional[str]): limit returned permissions to the specified csv permission keys (cloud required field) Returns: Dict[str, Dict[str, Dict[str, str]]] """ 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 if permissions is not None: params["permissions"] = permissions return self._get_json("mypermissions", params=params) # Priorities def priorities(self) -> list[Priority]: """Get a list of priority Resources from the server. Returns: List[Priority] """ 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: str) -> Priority: """Get a priority Resource from the server. Args: id (str): ID of the priority to get Returns: Priority """ return self._find_for_resource(Priority, id) # Projects def projects(self, expand: str | None = None) -> list[Project]: """Get a list of project Resources from the server visible to the current authenticated user. Args: expand (Optional[str]): extra information to fetch for each project such as projectKeys and description. Returns: List[Project] """ params = {} if expand is not None: params["expand"] = expand r_json = self._get_json("project", params=params) projects = [ Project(self._options, self._session, raw_project_json) for raw_project_json in r_json ] return projects def project(self, id: str, expand: str | None = None) -> Project: """Get a project Resource from the server. Args: id (str): ID or key of the project to get expand (Optional[str]): extra information to fetch for the project such as projectKeys and description. Returns: Project """ return self._find_for_resource(Project, id, expand=expand) # non-resource @translate_resource_args def project_avatars(self, project: str): """Get a dict of all avatars for a project visible to the current authenticated user. Args: project (str): 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: str, filename: str, size: int, avatar_img: bytes, contentType: str = None, auto_confirm: bool = 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 `Their Documentation `_ 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. Args: project (str): ID or key of the project to create the avatar in filename (str): name of the avatar file size (int): size of the avatar file avatar_img (bytes): file-like object holding the avatar contentType (str): explicit specification for the avatar image's content-type auto_confirm (bool): True to automatically confirm the temporary avatar by calling :py:meth:`confirm_project_avatar` with the return value of this method. (Default: ``False``) """ size_from_file = os.path.getsize(filename) if size != size_from_file: size = size_from_file params: dict[str, int | str] = {"filename": filename, "size": size} headers: dict[str, Any] = {"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: dict[str, Any] = 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: str, cropping_properties: dict[str, Any]): """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. Args: project (str): ID or key of the project to confirm the avatar in cropping_properties (Dict[str,Any]): 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: str, avatar: str): """Set a project's avatar. Args: project (str): ID or key of the project to set the avatar on avatar (str): 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: str, avatar: str) -> Response: """Delete a project's avatar. Args: project (str): ID or key of the project to delete the avatar from avatar (str): ID of the avatar to delete Returns: Response """ url = self._get_url("project/" + project + "/avatar/" + avatar) return self._session.delete(url) @translate_resource_args def project_components(self, project: str) -> list[Component]: """Get a list of component Resources present on a project. Args: project (str): ID or key of the project to get components from Returns: List[Component] """ 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: str) -> list[Version]: """Get a list of version Resources present on a project. Args: project (str): ID or key of the project to get versions from Returns: List[Version] """ 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 @translate_resource_args def get_project_version_by_name( self, project: str, version_name: str ) -> Version | None: """Get a version Resource by its name present on a project. Args: project (str): ID or key of the project to get versions from version_name (str): name of the version to search for Returns: Optional[Version] """ versions: list[Version] = self.project_versions(project) for version in versions: if version.name == version_name: return version return None @translate_resource_args def rename_version(self, project: str, old_name: str, new_name: str) -> None: """Rename a version Resource on a project. Args: project (str): ID or key of the project to get versions from old_name (str): old name of the version to rename new_name (str): new name of the version to rename """ version = self.get_project_version_by_name(project, old_name) if version: version.update(name=new_name) # non-resource @translate_resource_args def project_roles(self, project: str) -> dict[str, dict[str, str]]: """Get a dict of role names to resource locations for a project. Args: project (str): ID or key of the project to get roles from Returns: Dict[str, Dict[str, str]] """ path = "project/" + project + "/role" _rolesdict: dict[str, str] = self._get_json(path) rolesdict: dict[str, dict[str, str]] = {} for k, v in _rolesdict.items(): tmp: dict[str, str] = {} tmp["id"] = v.split("/")[-1] tmp["url"] = v rolesdict[k] = tmp return rolesdict # TODO(ssbarnea): return a list of Roles() @translate_resource_args def project_role(self, project: str, id: str) -> Role: """Get a role Resource. Args: project (str): ID or key of the project to get the role from id (str): ID of the role to get Returns: Role """ if isinstance(id, Number): id = f"{id}" return self._find_for_resource(Role, (project, id)) # Resolutions def resolutions(self) -> list[Resolution]: """Get a list of resolution Resources from the server. Returns: List[Resolution] """ 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: str) -> Resolution: """Get a resolution Resource from the server. Args: id (str): ID of the resolution to get Returns: Resolution """ return self._find_for_resource(Resolution, id) # Search def search_issues( self, jql_str: str, startAt: int = 0, maxResults: int = 50, validate_query: bool = True, fields: str | list[str] | None = "*all", expand: str | None = None, properties: str | None = None, json_result: bool = False, ) -> dict[str, Any] | ResultList[Issue]: """Get a :class:`~jira.client.ResultList` of issue Resources matching a JQL search string. Args: jql_str (str): The JQL search string. startAt (int): Index of the first issue to return. (Default: ``0``) maxResults (int): Maximum number of issues to return. Total number of results is available in the ``total`` attribute of the returned :class:`ResultList`. If maxResults evaluates to False, it will try to get all issues in batches. (Default: ``50``) validate_query (bool): True to validate the query. (Default: ``True``) fields (Optional[Union[str, List[str]]]): comma-separated string or list of issue fields to include in the results. Default is to include all fields. expand (Optional[str]): extra information to fetch inside each resource properties (Optional[str]): extra properties to fetch inside each result json_result (bool): True to return a JSON response. When set to False a :class:`ResultList` will be returned. (Default: ``False``) Returns: Union[Dict,ResultList]: Dict if ``json_result=True`` """ if isinstance(fields, str): fields = fields.split(",") elif fields is None: fields = ["*all"] # 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_cache: for i, field in enumerate(fields): if field in self._fields_cache: untranslate[self._fields_cache[field]] = fields[i] fields[i] = self._fields_cache[field] search_params = { "jql": jql_str, "startAt": startAt, "validateQuery": validate_query, "fields": fields, "expand": expand, "properties": properties, } 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, ) r_json: dict[str, Any] = self._get_json("search", params=search_params) return r_json issues = self._fetch_pages( Issue, "issues", "search", startAt, maxResults, search_params ) if untranslate: iss: Issue for iss in issues: for k, v in untranslate.items(): if iss.raw: if k in iss.raw.get("fields", {}): iss.raw["fields"][v] = iss.raw["fields"][k] return issues # Security levels def security_level(self, id: str) -> SecurityLevel: """Get a security level Resource. Args: id (str): ID of the security level to get Returns: SecurityLevel """ return self._find_for_resource(SecurityLevel, id) # Server info # non-resource def server_info(self) -> dict[str, Any]: """Get a dict of server information for this Jira instance. Returns: Dict[str, Any] """ retry = 0 j = self._get_json("serverInfo") while not j and retry < 3: self.log.warning( "Bug https://jira.atlassian.com/browse/JRA-59676 trying again..." ) retry += 1 j = self._get_json("serverInfo") return j def myself(self) -> dict[str, Any]: """Get a dict of server information for this Jira instance. Returns: Dict[str, Any] """ return self._get_json("myself") # Status def statuses(self) -> list[Status]: """Get a list of all status Resources from the server. Refer to :py:meth:`JIRA.issue_types_for_project` for getting statuses for a specific issue type within a specific project. Returns: List[Status] """ 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 issue_types_for_project(self, projectIdOrKey: str) -> list[IssueType]: """Get a list of issue types available within the project. Each project has a set of valid issue types and each issue type has a set of valid statuses. The valid statuses for a given issue type can be extracted via: `issue_type_x.statuses` Returns: List[IssueType] """ r_json = self._get_json(f"project/{projectIdOrKey}/statuses") issue_types = [ IssueType(self._options, self._session, raw_stat_json) for raw_stat_json in r_json ] return issue_types def status(self, id: str) -> Status: """Get a status Resource from the server. Args: id (str): ID of the status resource to get Returns: Status """ return self._find_for_resource(Status, id) # Category def statuscategories(self) -> list[StatusCategory]: """Get a list of status category Resources from the server. Returns: List[StatusCategory] """ r_json = self._get_json("statuscategory") statuscategories = [ StatusCategory(self._options, self._session, raw_stat_json) for raw_stat_json in r_json ] return statuscategories def statuscategory(self, id: int) -> StatusCategory: """Get a status category Resource from the server. Args: id (int): ID of the status category resource to get Returns: StatusCategory """ return self._find_for_resource(StatusCategory, id) # Users def user(self, id: str, expand: Any | None = None) -> User: """Get a user Resource from the server. Args: id (str): ID of the user to get expand (Optional[Any]): Extra information to fetch inside each resource Returns: User """ user = User( self._options, self._session, _query_param="accountId" if self._is_cloud else "username", ) params = {} if expand is not None: params["expand"] = expand user.find(id, params=params) return user def search_assignable_users_for_projects( self, username: str, projectKeys: str, startAt: int = 0, maxResults: int = 50, ) -> ResultList: """Get a list of user Resources that match the search string and can be assigned issues for projects. Args: username (str): A string to match usernames against projectKeys (str): Comma-separated list of project keys to check for issue assignment permissions startAt (int): Index of the first user to return (Default: ``0``) maxResults (int): Maximum number of users to return. If maxResults evaluates as False, it will try to get all users in batches. (Default: ``50``) Returns: ResultList """ 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: str | None = None, project: str | None = None, issueKey: str | None = None, expand: Any | None = None, startAt: int = 0, maxResults: int = 50, query: str | None = None, ): """Get a list of user Resources that match the search string for assigning or creating issues. "username" query parameter is deprecated in Jira Cloud; the expected parameter now is "query", which can just be the full email again. But the "user" parameter is kept for backwards compatibility, i.e. Jira Server/Data Center. 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. Args: username (Optional[str]): A string to match usernames against project (Optional[str]): Filter returned users by permission in this project (expected if a result will be used to create an issue) issueKey (Optional[str]): Filter returned users by this issue (expected if a result will be used to edit this issue) expand (Optional[Any]): Extra information to fetch inside each resource startAt (int): Index of the first user to return (Default: ``0``) maxResults (int): maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. (Default: ``50``) query (Optional[str]): Search term. It can just be the email. Returns: ResultList """ if not username and not query: raise ValueError( "Either 'username' or 'query' arguments must be specified." ) if username is not None: params = {"username": username} if query is not None: params = {"query": query} 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: str) -> dict[str, Any]: """Get a dict of avatars for the specified user. Args: username (str): the username to get avatars for Returns: Dict[str, Any] """ return self._get_json("user/avatars", params={"username": username}) def create_temp_user_avatar( self, user: str, filename: str, size: int, avatar_img: bytes, contentType: Any = None, auto_confirm: bool = 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 `Their Documentation `_ 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. Args: user (str): User to register the avatar for filename (str): name of the avatar file size (int): size of the avatar file avatar_img (bytes): file-like object containing the avatar contentType (Optional[Any]): explicit specification for the avatar image's content-type auto_confirm (bool): True to automatically confirm the temporary avatar by calling :py:meth:`confirm_user_avatar` with the return value of this method. (Default: ``False``) """ 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: dict[str, str | int] = { "username": user, "filename": filename, "size": size, } headers: dict[str, Any] 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: dict[str, Any] = json_loads(r) if auto_confirm: return self.confirm_user_avatar(user, cropping_properties) else: return cropping_properties def confirm_user_avatar(self, user: str, cropping_properties: dict[str, Any]): """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. Args: user (str): the user to confirm the avatar for cropping_properties (Dict[str,Any]): 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: str, avatar: str) -> Response: """Set a user's avatar. Args: username (str): the user to set the avatar for avatar (str): ID of the avatar to set Returns: Response """ return self._set_avatar( {"username": username}, self._get_url("user/avatar"), avatar ) def delete_user_avatar(self, username: str, avatar: str) -> Response: """Delete a user's avatar. Args: username (str): the user to delete the avatar from avatar (str): ID of the avatar to remove Returns: Response """ params = {"username": username} url = self._get_url("user/avatar/" + avatar) return self._session.delete(url, params=params) @translate_resource_args def delete_remote_link( self, issue: str | Issue, *, internal_id: str | None = None, global_id: str | None = None, ) -> Response: """Delete remote link from issue by internalId or globalId. Args: issue (str): Key (or Issue) of Issue internal_id (Optional[str]): InternalID of the remote link to delete global_id (Optional[str]): GlobalID of the remote link to delete Returns: Response """ if not ((internal_id is None) ^ (global_id is None)): raise ValueError("Must supply either 'internal_id' XOR 'global_id'.") if internal_id is not None: url = self._get_url(f"issue/{issue}/remotelink/{internal_id}") elif global_id is not None: # stop "&" and other special characters in global_id from messing around with the query global_id = urllib.parse.quote(global_id, safe="") url = self._get_url(f"issue/{issue}/remotelink?globalId={global_id}") return self._session.delete(url) def search_users( self, user: str | None = None, startAt: int = 0, maxResults: int = 50, includeActive: bool = True, includeInactive: bool = False, query: str | None = None, ) -> ResultList[User]: """Get a list of user Resources that match the specified search string. "username" query parameter is deprecated in Jira Cloud; the expected parameter now is "query", which can just be the full email again. But the "user" parameter is kept for backwards compatibility, i.e. Jira Server/Data Center. Args: user (Optional[str]): a string to match usernames, name or email against. startAt (int): index of the first user to return. maxResults (int): maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. includeActive (bool): True to include active users in the results. (Default: ``True``) includeInactive (bool): True to include inactive users in the results. (Default: ``False``) query (Optional[str]): Search term. It can just be the email. Returns: ResultList[User] """ if not user and not query: raise ValueError("Either 'user' or 'query' arguments must be specified.") params = { "username": user, "query": query, "includeActive": includeActive, "includeInactive": includeInactive, } return self._fetch_pages(User, None, "user/search", startAt, maxResults, params) def search_allowed_users_for_issue( self, user: str, issueKey: str = None, projectKey: str = None, startAt: int = 0, maxResults: int = 50, ) -> ResultList: """Get a list of user Resources that match a username string and have browse permission for the issue or project. Args: user (str): a string to match usernames against. issueKey (Optional[str]): find users with browse permission for this issue. projectKey (Optional[str]): find users with browse permission for this project. startAt (int): index of the first user to return. (Default: ``0``) maxResults (int): maximum number of users to return. If maxResults evaluates as False, it will try to get all items in batches. (Default: ``50``) Returns: ResultList """ params = {"query" if self._is_cloud else "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: str, project: str, description: str = None, releaseDate: Any = None, startDate: Any = None, archived: bool = False, released: bool = False, ) -> Version: """Create a version in a project and return a Resource for it. Args: name (str): name of the version to create project (str): key of the project to create the version in description (str): a description of the version releaseDate (Optional[Any]): the release date assigned to the version startDate (Optional[Any]): The start date for the version archived (bool): True to create an archived version. (Default: ``False``) released (bool): True to create a released version. (Default: ``False``) Returns: 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: str, after: str = None, position: str = None) -> Version: """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. Args: id (str): ID of the version to move after (str): the self attribute of a version to place the specified version after (that is, higher in the list) position (Optional[str]): the absolute position to move this version to: must be one of ``First``, ``Last``, ``Earlier``, or ``Later`` Returns: Version """ 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: str, expand: Any = None) -> Version: """Get a version Resource. Args: id (str): ID of the version to get expand (Optional[Any]): extra information to fetch inside each resource Returns: Version """ 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: str): """Get a dict of the counts of issues fixed and affected by a version. Args: id (str): the version to count issues for """ r_json: dict[str, Any] = 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: str): """Get the number of unresolved issues for a version. Args: id (str): ID of the version to count issues for """ r_json: dict[str, Any] = self._get_json( "version/" + id + "/unresolvedIssueCount" ) return r_json["issuesUnresolvedCount"] # Session authentication def session(self) -> User: """Get a dict of the current authenticated user's session information. Returns: User """ url = "{server}{auth_url}".format(**self._options) r = self._session.get(url) user = User(self._options, self._session, json_loads(r)) return user def kill_session(self) -> Response: """Destroy the session of the current authenticated user. Returns: Response """ url = self.server_url + "/rest/auth/latest/session" return self._session.delete(url) # Websudo def kill_websudo(self) -> Response | None: """Destroy the user's current WebSudo session. Works only for non-cloud deployments, for others does nothing. Returns: Optional[Response] """ if not self._is_cloud: url = self.server_url + "/rest/auth/1/websudo" return self._session.delete(url) return None # Utilities def _create_http_basic_session(self, username: str, password: str): """Creates a basic http session. Args: username (str): Username for the session password (str): Password for the username Returns: ResilientSession """ self._session.auth = (username, password) def _create_oauth_session(self, oauth: dict[str, Any]): from oauthlib.oauth1 import SIGNATURE_HMAC_SHA1 as DEFAULT_SHA from requests_oauthlib import OAuth1 try: from oauthlib.oauth1 import SIGNATURE_RSA as FALLBACK_SHA except ImportError: FALLBACK_SHA = DEFAULT_SHA _logging.debug("Fallback SHA 'SIGNATURE_RSA_SHA1' could not be imported.") for sha_type in (oauth.get("signature_method"), DEFAULT_SHA, FALLBACK_SHA): if sha_type is None: continue oauth_instance = OAuth1( oauth["consumer_key"], rsa_key=oauth["key_cert"], signature_method=sha_type, resource_owner_key=oauth["access_token"], resource_owner_secret=oauth["access_token_secret"], ) self._session.auth = oauth_instance try: self.myself() _logging.debug(f"OAuth1 succeeded with signature_method={sha_type}") return # successful response, return with happy session except JIRAError: _logging.exception( f"Failed to create OAuth session with signature_method={sha_type}.\n" + "Attempting fallback method(s)." + "Consider specifying the signature via oauth['signature_method']." ) if sha_type is FALLBACK_SHA: raise # We have exhausted our options, bubble up exception def _create_kerberos_session( self, kerberos_options: dict[str, Any] = None, ): if kerberos_options is None: kerberos_options = {} from requests_kerberos import DISABLED, OPTIONAL, HTTPKerberosAuth if kerberos_options.get("mutual_authentication", "OPTIONAL") == "OPTIONAL": mutual_authentication = OPTIONAL elif kerberos_options.get("mutual_authentication") == "DISABLED": mutual_authentication = DISABLED else: raise ValueError( "Unknown value for mutual_authentication: %s" % kerberos_options["mutual_authentication"] ) self._session.auth = HTTPKerberosAuth( mutual_authentication=mutual_authentication ) def _add_client_cert_to_session(self): """Adds the client certificate to the session. If configured through the constructor. https://docs.python-requests.org/en/master/user/advanced/#client-side-certificates - str: a single file (containing the private key and the certificate) - Tuple[str,str] a tuple of both files’ paths """ client_cert: str | tuple[str, str] = self._options["client_cert"] self._session.cert = client_cert def _add_ssl_cert_verif_strategy_to_session(self): """Adds verification strategy for host SSL certificates. If configured through the constructor. https://docs.python-requests.org/en/master/user/advanced/#ssl-cert-verification - str: Path to a `CA_BUNDLE` file or directory with certificates of trusted CAs. - bool: True/False """ ssl_cert: bool | str = self._options["verify"] self._session.verify = ssl_cert @staticmethod def _timestamp(dt: datetime.timedelta = None): t = datetime.datetime.utcnow() if dt is not None: t += dt return calendar.timegm(t.timetuple()) def _create_jwt_session(self, jwt: dict[str, Any]): try: jwt_auth = JWTAuth(jwt["secret"], alg="HS256") except NameError as e: self.log.error("JWT authentication requires requests_jwt") raise e jwt_auth.set_header_format("JWT %s") 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.auth = jwt_auth def _create_token_session(self, token_auth: str): """Creates token-based session. Header structure: "authorization": "Bearer ". """ self._session.auth = TokenAuth(token_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: str, base: str = JIRA_BASE_URL) -> str: """Returns the full url based on Jira base url and the path provided. Using the API version specified during the __init__. Args: path (str): The subpath desired. base (Optional[str]): The base url which should be prepended to the path Returns: str: Fully qualified URL """ options = self._options.copy() options.update({"path": path}) return base.format(**options) def _get_latest_url(self, path: str, base: str = JIRA_BASE_URL) -> str: """Returns the full url based on Jira base url and the path provided. Using the latest API endpoint. Args: path (str): The subpath desired. base (Optional[str]): The base url which should be prepended to the path Returns: str: Fully qualified URL """ options = self._options.copy() options.update({"path": path, "rest_api_version": "latest"}) return base.format(**options) def _get_json( self, path: str, params: dict[str, Any] = None, base: str = JIRA_BASE_URL ): """Get the json for a given path and params. Args: path (str): The subpath required params (Optional[Dict[str, Any]]): Parameters to filter the json query. base (Optional[str]): The Base Jira URL, defaults to the instance base. Returns: Union[Dict[str, Any], List[Dict[str, str]]] """ url = self._get_url(path, base) r = self._session.get(url, params=params) try: r_json = json_loads(r) except ValueError as e: self.log.error(f"{e}\n{r.text if r else r}") raise e return r_json def _find_for_resource( self, resource_cls: Any, ids: tuple[str, str] | tuple[str | int, str] | int | str, expand=None, ) -> Any: """Uses the find method of the provided Resource class. Args: resource_cls (Any): Any instance of :py:class`Resource` ids (Union[Tuple[str, str], int, str]): The arguments to the Resource's ``find()`` expand ([type], optional): The value for the expand property in the Resource's ``find()`` params. Defaults to None. Raises: JIRAError: If the Resource cannot be found Returns: Any: A class of the same type as ``resource_cls`` """ 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, str(ids)) return resource def _try_magic(self): try: import weakref import magic 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: bytes) -> str | None: """Get the MIME type for a given stream of bytes. Args: buff (bytes): Stream of bytes Returns: Optional[str]: the MIME type """ if self._magic is not None: return self._magic.id_buffer(buff) try: return mimetypes.guess_type("f." + str(imghdr.what(0, buff)))[0] except (OSError, TypeError): self.log.warning( "Couldn't detect content type of avatar image" ". Specify the 'contentType' parameter explicitly." ) return None def rename_user(self, old_user: str, new_user: str): """Rename a Jira user. Args: old_user (str): Old username login new_user (str): New username login """ if self._version > (6, 0, 0): url = self._get_latest_url("user") payload = {"name": new_user} params = {"username": old_user} # raw displayName self.log.debug(f"renaming {self.user(old_user).emailAddress}") self._session.put(url, params=params, data=json.dumps(payload)) else: raise NotImplementedError( "Support for renaming users in Jira " "< 6.0.0 has been removed." ) def delete_user(self, username: str) -> bool: """Deletes a Jira User. Args: username (str): Username to delete Returns: bool: Success of user deletion """ url = self._get_latest_url(f"user/?username={username}") r = self._session.delete(url) if 200 <= r.status_code <= 299: return True self.log.error(r.status_code) return False def deactivate_user(self, username: str) -> str | int: """Disable/deactivate the user. Args: username (str): User to be deactivated. Returns: Union[str, int] """ if self._is_cloud: # Disabling users now needs cookie auth in the Cloud - # see https://jira.atlassian.com/browse/ID-6230 if "authCookie" not in vars(self): user = self.session() if user.raw is None: raise JIRAError("Can not log in!") self.authCookie = "{}={}".format( user.raw["session"]["name"], user.raw["session"]["value"], ) url = ( self._options["server"] + f"/admin/rest/um/1/user/deactivate?username={username}" ) # We can't use our existing session here # this endpoint is fragile and objects to extra headers try: r = requests.post( url, headers={ "Cookie": self.authCookie, "Content-Type": "application/json", }, proxies=self._session.proxies, data={}, ) if r.status_code == 200: return True self.log.warning( f"Got response from deactivating {username}: {r.status_code}" ) return r.status_code except Exception as e: self.log.error(f"Error Deactivating {username}: {e}") raise JIRAError(f"Error Deactivating {username}: {e}") else: url = self.server_url + "/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 self.log.warning( f"Got response from deactivating {username}: {r.status_code}" ) return r.status_code except Exception as e: self.log.error(f"Error Deactivating {username}: {e}") raise JIRAError(f"Error Deactivating {username}: {e}") def reindex(self, force: bool = False, background: bool = True) -> bool: """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 background reindex only if Jira thinks it should do it. Args: force (bool): True to reindex even if Jira doesn't say this is needed. (Default: ``False``) background (bool): True to reindex in background, slower but does not impact the users. (Default: ``True``) Returns: bool: True if reindexing is in progress or not needed """ # /secure/admin/IndexAdmin.jspa # /secure/admin/jira/IndexProgress.jspa?taskId=1 indexingStrategy = "background" if background else "stoptheworld" url = self.server_url + "/secure/admin/jira/IndexReIndex.jspa" r = self._session.get(url, headers=self._options["headers"]) if r.status_code == 503: # self.log.warning("Jira returned 503, this could mean that a full reindex is in progress.") return 503 # type: ignore # FIXME: is this a bug? if ( not r.text.find("To perform the re-index now, please go to the") and not force ): return True if r.text.find("All issues are being re-indexed"): self.log.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 self.log.error("Failed to reindex jira, probably a bug.") return False def backup( self, filename: str = "backup.zip", attachments: bool = False ) -> bool | int | None: """Will call jira export to backup as zipped xml. Returning with success does not mean that the backup process finished. Args: filename (str): the filename for the backup (Default: "backup.zip") attachments (bool): True to also backup attachments (Default: ``False``) Returns: Union[bool, int]: Returns True if successful else it returns the statuscode of the Response or False """ payload: Any # _session.post is pretty open if self._is_cloud: url = self.server_url + "/rest/backup/1/export/runbackup" payload = json.dumps({"cbAttachments": attachments}) self._options["headers"]["X-Requested-With"] = "XMLHttpRequest" else: url = self.server_url + "/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 self.log.warning(f"Got {r.status_code} response from calling backup.") return r.status_code except Exception as e: self.log.error("I see %s", e) return False def backup_progress(self) -> dict[str, Any] | None: """Return status of cloud backup as a dict. Is there a way to get progress for Server version? Returns: Optional[Dict[str, Any]] """ epoch_time = int(time.time() * 1000) if self._is_cloud: url = self.server_url + "/rest/obm/1.0/getprogress?_=%i" % epoch_time else: self.log.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: self.log.warning( f"Unable to find backup info. You probably need to initiate a new backup. {pe}" ) return None for k in root.keys(): progress[k] = root.get(k) return progress def backup_complete(self) -> bool | None: """Return boolean based on 'alternativePercentage' and 'size' returned from backup_progress (cloud only).""" if not self._is_cloud: self.log.warning("This functionality is not available in Server version") return None status = self.backup_progress() perc_search = re.search(r"\s([0-9]*)\s", status["alternativePercentage"]) perc_complete = int( perc_search.group(1) # type: ignore # ignore that re.search can return None ) file_size = int(status["size"]) return perc_complete >= 100 and file_size > 0 def backup_download(self, filename: str = None): """Download backup file from WebDAV (cloud only).""" if not self._is_cloud: self.log.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.server_url + "/webdav/backupmanager/" + remote_file try: self.log.debug(f"Writing file to {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: self.log.error(f"Something went wrong with download: {resp.text}") raise JIRAError(resp.text) for block in resp.iter_content(1024): file.write(block) except JIRAError as je: self.log.error(f"Unable to access remote backup file: {je}") except OSError as ioe: self.log.error(ioe) return None def current_user(self, field: str | None = None) -> str: """Return the `accountId` (Cloud) else `username` of the current user. For anonymous users it will return a value that evaluates as False. Args: field (Optional[str]): the name of the identifier field. Defaults to "accountId" for Jira Cloud, else "username" Returns: str: User's `accountId` (Cloud) else `username`. """ if not hasattr(self, "_myself"): url = self._get_url("myself") r = self._session.get(url, headers=self._options["headers"]) r_json: dict[str, str] = json_loads(r) self._myself = r_json if field is None: # Note: For Self-Hosted 'displayName' can be changed, # but 'name' and 'key' cannot, so should be identifying properties. field = "accountId" if self._is_cloud else "name" return self._myself[field] def delete_project(self, pid: str | Project) -> bool | None: """Delete project from Jira. Args: pid (Union[str, Project]): Jira projectID or Project or slug Raises: JIRAError: If project not found or not enough permissions ValueError: If pid parameter is not Project, slug or ProjectID Returns: bool: True if project was deleted """ # allows us to call it with Project objects if isinstance(pid, Project) and hasattr(pid, "id"): pid = str(pid.id) url = self._get_url(f"project/{pid}") r = self._session.delete(url) if r.status_code == 403: raise JIRAError("Not enough permissions to delete project") if r.status_code == 404: raise JIRAError("Project not found in Jira") return r.ok def _gain_sudo_session(self, options, destination): url = self.server_url + "/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, ) @lru_cache(maxsize=None) def templates(self) -> dict: url = self.server_url + "/rest/project-templates/latest/templates" r = self._session.get(url) data: dict[str, Any] = json_loads(r) templates = {} if "projectTemplatesGroupedByType" in data: for group in data["projectTemplatesGroupedByType"]: for t in group["projectTemplates"]: templates[t["name"]] = t # pprint(templates.keys()) return templates @lru_cache(maxsize=None) def permissionschemes(self): url = self._get_url("permissionscheme") r = self._session.get(url) data: dict[str, Any] = json_loads(r) return data["permissionSchemes"] @lru_cache(maxsize=None) def issue_type_schemes(self) -> list[IssueTypeScheme]: """Get all issue type schemes defined (Admin required). Returns: List[IssueTypeScheme]: All the Issue Type Schemes available to the currently logged in user. """ url = self._get_url("issuetypescheme") r = self._session.get(url) data: dict[str, Any] = json_loads(r) return data["schemes"] @lru_cache(maxsize=None) def issuesecurityschemes(self): url = self._get_url("issuesecurityschemes") r = self._session.get(url) data: dict[str, Any] = json_loads(r) return data["issueSecuritySchemes"] @lru_cache(maxsize=None) def projectcategories(self): url = self._get_url("projectCategory") r = self._session.get(url) data = json_loads(r) return data @lru_cache(maxsize=None) def avatars(self, entity="project"): url = self._get_url(f"avatar/{entity}/system") r = self._session.get(url) data: dict[str, Any] = json_loads(r) return data["system"] @lru_cache(maxsize=None) def notificationschemes(self): # TODO(ssbarnea): implement pagination support url = self._get_url("notificationscheme") r = self._session.get(url) data: dict[str, Any] = json_loads(r) return data["values"] @lru_cache(maxsize=None) def screens(self): # TODO(ssbarnea): implement pagination support url = self._get_url("screens") r = self._session.get(url) data: dict[str, Any] = json_loads(r) return data["values"] @lru_cache(maxsize=None) def workflowscheme(self): # TODO(ssbarnea): implement pagination support url = self._get_url("workflowschemes") r = self._session.get(url) data = json_loads(r) return data # ['values'] @lru_cache(maxsize=None) def workflows(self): # TODO(ssbarnea): implement pagination support url = self._get_url("workflow") r = self._session.get(url) data = json_loads(r) return data # ['values'] def delete_screen(self, id: str): url = self._get_url(f"screens/{id}") r = self._session.delete(url) data = json_loads(r) self.screens.cache_clear() return data def delete_permissionscheme(self, id: str): url = self._get_url(f"permissionscheme/{id}") r = self._session.delete(url) data = json_loads(r) self.permissionschemes.cache_clear() return data def get_issue_type_scheme_associations(self, id: str) -> list[Project]: """For the specified issue type scheme, returns all of the associated projects. (Admin required). Args: id (str): The issue type scheme id. Returns: List[Project]: Associated Projects for the Issue Type Scheme. """ url = self._get_url(f"issuetypescheme/{id}/associations") r = self._session.get(url) data = json_loads(r) return data def create_project( self, key: str, name: str = None, assignee: str = None, ptype: str = "software", template_name: str = None, avatarId: int = None, issueSecurityScheme: int = None, permissionScheme: int = None, projectCategory: int = None, notificationScheme: int = 10000, categoryId: int = None, url: str = "", ): """Create a project with the specified parameters. Args: key (str): Mandatory. Must match Jira project key requirements, usually only 2-10 uppercase characters. name (Optional[str]): If not specified it will use the key value. assignee (Optional[str]): Key of the lead, if not specified it will use current user. ptype (Optional[str]): Determines the type of project that should be created. Defaults to 'software'. template_name (Optional[str]): 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. avatarId (Optional[int]): ID of the avatar to use for the project. issueSecurityScheme (Optional[int]): Determines the security scheme to use. If none provided, will fetch the scheme named 'Default' or the first scheme returned. permissionScheme (Optional[int]): Determines the permission scheme to use. If none provided, will fetch the scheme named 'Default Permission Scheme' or the first scheme returned. projectCategory (Optional[int]): Determines the category the project belongs to. If none provided, will fetch the one named 'Default' or the first category returned. notificationScheme (Optional[int]): Determines the notification scheme to use. categoryId (Optional[int]): Same as projectCategory. Can be used interchangeably. url (Optional[string]): A link to information about the project, such as documentation. Returns: Union[bool,int]: Should evaluate to False if it fails otherwise it will be the new project id. """ template_key = None if assignee is None: assignee = self.current_user() if name is None: name = key ps_list: list[dict[str, Any]] if permissionScheme is None: ps_list = self.permissionschemes() for sec in ps_list: if sec["name"] == "Default Permission Scheme": permissionScheme = sec["id"] break if permissionScheme is None and ps_list: permissionScheme = ps_list[0]["id"] if issueSecurityScheme is None: ps_list = self.issuesecurityschemes() for sec in ps_list: if sec["name"] == "Default": # no idea which one is default issueSecurityScheme = sec["id"] break if issueSecurityScheme is None and ps_list: issueSecurityScheme = ps_list[0]["id"] # If categoryId provided instead of projectCategory, attribute the categoryId value # to the projectCategory variable projectCategory = ( categoryId if categoryId and not projectCategory else projectCategory ) if projectCategory is None: ps_list = self.projectcategories() for sec in ps_list: if sec["name"] == "Default": # no idea which one is default projectCategory = sec["id"] break if projectCategory is None and ps_list: projectCategory = ps_list[0]["id"] # Atlassian for failing to provide an API to get projectTemplateKey values # Possible values are just hardcoded and obviously depending on Jira version. # https://developer.atlassian.com/cloud/jira/platform/rest/v3/?_ga=2.88310429.766596084.1562439833-992274574.1559129176#api-rest-api-3-project-post # https://jira.atlassian.com/browse/JRASERVER-59658 # preference list for picking a default template if not template_name: # https://confluence.atlassian.com/jirakb/creating-projects-via-rest-api-in-jira-963651978.html template_key = ( "com.pyxis.greenhopper.jira:gh-simplified-basic" if self._is_cloud else "com.pyxis.greenhopper.jira:basic-software-development-template" ) # https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-projects/#api-rest-api-2-project-get # template_keys = [ # "com.pyxis.greenhopper.jira:gh-simplified-agility-kanban", # "com.pyxis.greenhopper.jira:gh-simplified-agility-scrum", # "com.pyxis.greenhopper.jira:gh-simplified-basic", # "com.pyxis.greenhopper.jira:gh-simplified-kanban-classic", # "com.pyxis.greenhopper.jira:gh-simplified-scrum-classic", # "com.atlassian.servicedesk:simplified-it-service-desk", # "com.atlassian.servicedesk:simplified-internal-service-desk", # "com.atlassian.servicedesk:simplified-external-service-desk", # "com.atlassian.jira-core-project-templates:jira-core-simplified-content-management", # "com.atlassian.jira-core-project-templates:jira-core-simplified-document-approval", # "com.atlassian.jira-core-project-templates:jira-core-simplified-lead-tracking", # "com.atlassian.jira-core-project-templates:jira-core-simplified-process-control", # "com.atlassian.jira-core-project-templates:jira-core-simplified-procurement", # "com.atlassian.jira-core-project-templates:jira-core-simplified-project-management", # "com.atlassian.jira-core-project-templates:jira-core-simplified-recruitment", # "com.atlassian.jira-core-project-templates:jira-core-simplified-task-", # "com.atlassian.jira.jira-incident-management-plugin:im-incident-management", # ] # possible_templates = [ # "Scrum software development", # have Bug # "Agility", # cannot set summary # "Bug tracking", # "JIRA Classic", # "JIRA Default Schemes", # "Basic software development", # "Project management", # "Kanban software development", # "Task management", # "Basic", # does not have Bug # "Content Management", # "Customer service", # "Document Approval", # "IT Service Desk", # "Lead Tracking", # "Process management", # "Procurement", # "Recruitment", # ] # templates = self.templates() # if not template_name: # for k, v in templates.items(): # if v['projectTypeKey'] == type: # template_name = k # template_name = next((t for t in templates if t['projectTypeKey'] == 'x')) # template_key = templates[template_name]["projectTemplateModuleCompleteKey"] # project_type_key = templates[template_name]["projectTypeKey"] # https://confluence.atlassian.com/jirakb/creating-a-project-via-rest-based-on-jira-default-schemes-744325852.html # see https://confluence.atlassian.com/jirakb/creating-projects-via-rest-api-in-jira-963651978.html payload = { "name": name, "key": key, "projectTypeKey": ptype, "projectTemplateKey": template_key, "leadAccountId" if self._is_cloud else "lead": assignee, "assigneeType": "PROJECT_LEAD", "description": "", # "avatarId": 13946, "permissionScheme": int(permissionScheme), "notificationScheme": notificationScheme, "url": url, } if issueSecurityScheme: payload["issueSecurityScheme"] = int(issueSecurityScheme) if projectCategory: payload["categoryId"] = int(projectCategory) url = self._get_url("project") r = self._session.post(url, data=json.dumps(payload)) r.raise_for_status() r_json = json_loads(r) return r_json def add_user( self, username: str, email: str, directoryId: int = 1, password: str = None, fullname: str = None, notify: bool = False, active: bool = True, ignore_existing: bool = False, application_keys: list | None = None, ): """Create a new Jira user. Args: username (str): the username of the new user email (str): email address of the new user directoryId (int): The directory ID the new user should be a part of (Default: ``1``) password (Optional[str]): Optional, the password for the new user fullname (Optional[str]): Optional, the full name of the new user notify (bool): True to send a notification to the new user. (Default: ``False``) active (bool): True to make the new user active upon creation. (Default: ``True``) ignore_existing (bool): True to ignore existing users. (Default: ``False``) application_keys (Optional[list]): Keys of products user should have access to Raises: JIRAError: If username already exists and `ignore_existing` has not been set to `True`. Returns: bool: Whether the user creation was successful. """ 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._get_latest_url("user") # implementation based on # https://docs.atlassian.com/jira/REST/ondemand/#d2e5173 x: dict[str, Any] = OrderedDict() x["displayName"] = fullname x["emailAddress"] = email x["name"] = username if password: x["password"] = password if notify: x["notification"] = "True" if application_keys is not None: x["applicationKeys"] = application_keys payload = json.dumps(x) try: self._session.post(url, data=payload) except JIRAError as e: if e.response: 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: str, group: str) -> bool | dict[str, Any]: """Add a user to an existing group. Args: username (str): Username that will be added to specified group. group (str): Group that the user will be added to. Returns: Union[bool,Dict[str,Any]]: json response from Jira server for success or a value that evaluates as False in case of failure. """ url = self._get_latest_url("group/user") x = {"groupname": group} y = {"name": username} payload = json.dumps(y) r: dict[str, Any] = 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: str, groupname: str) -> bool: """Remove a user from a group. Args: username (str): The user to remove from the group. groupname (str): The group that the user will be removed from. Returns: bool """ url = self._get_latest_url("group/user") x = {"groupname": groupname, "username": username} self._session.delete(url, params=x) return True def role(self) -> list[dict[str, Any]]: """Return Jira role information. Returns: List[Dict[str,Any]]: List of current user roles """ # https://developer.atlassian.com/cloud/jira/platform/rest/v3/?utm_source=%2Fcloud%2Fjira%2Fplatform%2Frest%2F&utm_medium=302#api-rest-api-3-role-get url = self._get_latest_url("role") r = self._session.get(url) data: list[dict[str, Any]] = json_loads(r) return data # 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: str, customfield: str, schemeid: str): url = self.server_url + "/rest/idalko-igrid/1.0/datagrid/data" if str(customfield).isdigit(): customfield = f"customfield_{customfield}" params = { "_issueId": issueid, "_fieldId": customfield, "_confSchemeId": schemeid, } r = self._session.get(url, headers=self._options["headers"], params=params) return json_loads(r) """ Define the functions that interact with Jira Agile. """ @translate_resource_args def boards( self, startAt: int = 0, maxResults: int = 50, type: str = None, name: str = None, projectKeyOrID=None, ) -> ResultList[Board]: """Get a list of board resources. Args: startAt: The starting index of the returned boards. Base index: 0. maxResults: The maximum number of boards to return per page. Default: 50 type: Filters results to boards of the specified type. Valid values: scrum, kanban. name: Filters results to boards that match or partially match the specified name. projectKeyOrID: Filters results to boards that match the specified project key or ID. Returns: ResultList[Board] """ params = {} if type: params["type"] = type if name: params["name"] = name if projectKeyOrID: params["projectKeyOrId"] = projectKeyOrID return self._fetch_pages( Board, "values", "board", startAt, maxResults, params, base=self.AGILE_BASE_URL, ) @translate_resource_args def sprints( self, board_id: int, extended: bool | None = None, startAt: int = 0, maxResults: int = 50, state: str = None, ) -> ResultList[Sprint]: """Get a list of sprint Resources. Args: board_id (int): the board to get sprints from extended (bool): Deprecated. startAt (int): the index of the first sprint to return (0 based) maxResults (int): the maximum number of sprints to return state (str): Filters results to sprints in specified states. Valid values: `future`, `active`, `closed`. You can define multiple states separated by commas Returns: ResultList[Sprint]: List of sprints. """ params = {} if state: params["state"] = state if extended is not None: DeprecationWarning("The `extended` argument is deprecated") return self._fetch_pages( Sprint, "values", f"board/{board_id}/sprint", startAt, maxResults, params, self.AGILE_BASE_URL, ) def sprints_by_name( self, id: str | int, extended: bool = False, state: str = None ) -> dict[str, dict[str, Any]]: """Get a dictionary of sprint Resources where the name of the sprint is the key. Args: board_id (int): the board to get sprints from extended (bool): Deprecated. state (str): Filters results to sprints in specified states. Valid values: `future`, `active`, `closed`. You can define multiple states separated by commas Returns: Dict[str, Dict[str, Any]]: dictionary of sprints with the sprint name as key """ sprints = {} for s in self.sprints(id, extended=extended, state=state): if s.name not in sprints: sprints[s.name] = s.raw else: raise JIRAError( f"There are multiple sprints defined with the name {s.name} on board id {id},\n" f"returning a dict with sprint names as a key, assumes unique names for each sprint" ) return sprints def update_sprint( self, id: str | int, name: str | None = None, startDate: Any | None = None, endDate: Any | None = None, state: str | None = None, ) -> dict[str, Any]: """Updates the sprint with the given values. Args: id (Union[str, int]): The id of the sprint to update name (Optional[str]): The name to update your sprint to startDate (Optional[Any]): The start date for the sprint endDate (Optional[Any]): The start date for the sprint state: (Optional[str]): The start date for the sprint Returns: Dict[str, Any] """ payload = {} if name: payload["name"] = name if startDate: payload["startDate"] = startDate if endDate: payload["endDate"] = endDate if state: payload["state"] = state url = self._get_url(f"sprint/{id}", base=self.AGILE_BASE_URL) r = self._session.put(url, data=json.dumps(payload)) return json_loads(r) def incompletedIssuesEstimateSum(self, board_id: str, sprint_id: str): """Return the total incompleted points this sprint.""" data: dict[str, Any] = self._get_json( f"rapid/charts/sprintreport?rapidViewId={board_id}&sprintId={sprint_id}", base=self.AGILE_BASE_URL, ) return data["contents"]["incompletedIssuesEstimateSum"]["value"] def removed_issues(self, board_id: str, sprint_id: str): """Return the completed issues for the sprint. Returns: List[Issue] """ r_json: dict[str, Any] = self._get_json( f"rapid/charts/sprintreport?rapidViewId={board_id}&sprintId={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: str, sprint_id: str): """Return the total incompleted points this sprint.""" data: dict[str, Any] = self._get_json( f"rapid/charts/sprintreport?rapidViewId={board_id}&sprintId={sprint_id}", base=self.AGILE_BASE_URL, ) return data["contents"]["puntedIssuesEstimateSum"]["value"] # TODO(ssbarnea): remove sprint_info() method, sprint() method suit the convention more def sprint_info(self, board_id: str, sprint_id: str) -> dict[str, Any]: """Return the information about a sprint. Args: board_id (str): the board retrieving issues from. Deprecated and ignored. sprint_id (str): the sprint retrieving issues from Returns: Dict[str, Any] """ sprint = Sprint(self._options, self._session) sprint.find(sprint_id) return sprint.raw def sprint(self, id: int) -> Sprint: """Return the information about a sprint. Args: sprint_id (int): the sprint retrieving issues from Returns: Sprint """ 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: str, filter_id: str, project_ids: str = None, preset: str = "scrum", location_type: Literal["user", "project"] = "user", location_id: str | None = None, ) -> Board: """Create a new board for the ``project_ids``. Args: name (str): name of the Board (<255 characters). filter_id (str): the Filter to use to create the Board. Note: if the user does not have the 'Create shared objects' permission and tries to create a shared board, a private board will be created instead (remember that board sharing depends on the filter sharing). project_ids (str): Deprecated. See location_id. preset (str): What preset/type to use for this Board, options: kanban, scrum, agility. (Default: "scrum") location_type (str): the location type. Available in Cloud. (Default: "user") location_id (Optional[str]): aka ``projectKeyOrId``. The id of Project that the Board should be located under. Omit this for a 'user' location_type. Available in Cloud. Returns: Board: The newly created board """ payload: dict[str, Any] = {} if project_ids is not None: DeprecationWarning( "project_ids is deprecated and ignored. " + "Use filter_id and location_id with `location_type='project'`" ) if location_id is not None: location_id = self.project(location_id).id payload["name"] = name payload["filterId"] = filter_id payload["type"] = preset if self._is_cloud: payload["location"] = {"type": location_type} if location_type not in ("user",): payload["location"].update({"projectKeyOrId": location_id}) url = self._get_url("board", 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: str, board_id: int, startDate: Any | None = None, endDate: Any | None = None, ) -> Sprint: """Create a new sprint for the ``board_id``. Args: name (str): Name of the sprint board_id (int): Which board the sprint should be assigned. startDate (Optional[Any]): Start date for the sprint. endDate (Optional[Any]): End date for the sprint. Returns: Sprint: The newly created Sprint """ payload: dict[str, Any] = {"name": name} if startDate: payload["startDate"] = startDate if endDate: payload["endDate"] = endDate raw_issue_json: dict[str, Any] 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: int, issue_keys: list[str]) -> Response: """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. Args: sprint_id (int): the sprint to add issues to issue_keys (List[str]): the issues to add to the sprint Returns: Response """ url = self._get_url(f"sprint/{sprint_id}/issue", base=self.AGILE_BASE_URL) payload = {"issues": issue_keys} return self._session.post(url, data=json.dumps(payload)) def add_issues_to_epic( self, epic_id: str, issue_keys: str | list[str], ignore_epics: bool = None ) -> Response: """Add the issues in ``issue_keys`` to the ``epic_id``. Issues can only exist in one Epic! Args: epic_id (str): The ID for the epic where issues should be added. issue_keys (Union[str, List[str]]): The list (or comma separated str) of issues to add to the epic ignore_epics (bool): Deprecated. Returns: Response """ data: dict[str, Any] = {} data["issues"] = ( issue_keys.split(",") if isinstance(issue_keys, str) else list(issue_keys) ) if ignore_epics is not None: DeprecationWarning("`ignore_epics` is Deprecated") url = self._get_url(f"epic/{epic_id}/issue", base=self.AGILE_BASE_URL) return self._session.post(url, data=json.dumps(data)) def rank( self, issue: str, next_issue: str | None = None, prev_issue: str | None = None, ) -> Response: """Rank an issue before/after another using the default Ranking field, the one named 'Rank'. Pass only ONE of `next_issue` or `prev_issue`. Args: issue (str): issue key of the issue to be ranked before/after the second one. next_issue (str): issue key that the first issue is to be ranked before. prev_issue (str): issue key that the first issue is to be ranked after. Returns: Response """ # TODO: Jira Agile API supports moving more than one issue. if next_issue is None and prev_issue is None: raise ValueError("One of 'next_issue' or 'prev_issue' must be specified") elif next_issue is not None and prev_issue is not None: raise ValueError( "Only one of 'next_issue' or 'prev_issue' may be specified" ) if next_issue is not None: before_or_after = "Before" other_issue = next_issue elif prev_issue is not None: before_or_after = "After" other_issue = prev_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"] url = self._get_url("issue/rank", base=self.AGILE_BASE_URL) payload = { "issues": [issue], f"rank{before_or_after}Issue": other_issue, "rankCustomFieldId": self._rank, } return self._session.put(url, data=json.dumps(payload)) def move_to_backlog(self, issue_keys: list[str]) -> Response: """Move issues in ``issue_keys`` to the backlog, removing them from all sprints that have not been completed. Args: issue_keys (List[str]): the issues to move to the backlog Raises: JIRAError: If moving issues to backlog fails Returns: Response """ url = self._get_url("backlog/issue", base=self.AGILE_BASE_URL) payload = {"issues": issue_keys} # TODO: should be list of issues return self._session.post(url, data=json.dumps(payload)) jira-3.5.2/jira/config.py000066400000000000000000000074401444726022700152270ustar00rootroot00000000000000"""Config handler. 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. """ from __future__ import annotations import configparser import logging import os import sys from jira.client import JIRA def get_jira( profile: str | None = None, url: str = "http://localhost:2990", username: str = "admin", password: str = "admin", appid=None, autofix=False, verify: bool | str = True, ): """Return a JIRA object by loading the connection details from the `config.ini` file. Args: profile (Optional[str]): The name of the section from config.ini file that stores server config url/username/password url (str): URL of the Jira server username (str): username to use for authentication password (str): password to use for authentication appid: appid autofix: autofix verify (Union[bool, str]): True to indicate whether SSL certificates should be verified or str path to a CA_BUNDLE file or directory with certificates of trusted CAs. (Default: ``True``) Returns: 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 if isinstance(verify, bool): verify = "yes" if verify else "no" else: verify = verify config = configparser.ConfigParser( defaults={ "user": None, "pass": None, "appid": appid, "autofix": autofix, "verify": verify, }, allow_no_value=True, ) config_file = findfile("config.ini") if config_file: logging.debug(f"Found {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") try: verify = config.getboolean(profile, "verify") except ValueError: verify = config.get(profile, "verify") else: raise OSError( "%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-3.5.2/jira/exceptions.py000066400000000000000000000045671444726022700161520ustar00rootroot00000000000000from __future__ import annotations import os import tempfile from requests import Response class JIRAError(Exception): """General error raised for all problems in operation of the client.""" def __init__( self, text: str = None, status_code: int = None, url: str = None, request: Response = None, response: Response = None, **kwargs, ): """Creates a JIRAError. Args: text (Optional[str]): Message for the error. status_code (Optional[int]): Status code for the error. url (Optional[str]): Url related to the error. request (Optional[requests.Response]): Request made related to the error. response (Optional[requests.Response]): Response received related to the error. **kwargs: Will be used to get request headers. """ 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 = "PYJIRA_LOG_TO_TEMPFILE" in os.environ self.ci_run = "GITHUB_ACTION" in os.environ def __str__(self) -> str: t = f"JiraError HTTP {self.status_code}" if self.url: t += f" url: {self.url}" details = "" if self.request is not None: if hasattr(self.request, "headers"): details += f"\n\trequest headers = {self.request.headers}" if hasattr(self.request, "text"): details += f"\n\trequest text = {self.request.text}" if self.response is not None: if hasattr(self.response, "headers"): details += f"\n\tresponse headers = {self.response.headers}" if hasattr(self.response, "text"): details += f"\n\tresponse text = {self.response.text}" if self.log_to_tempfile: # Only log to tempfile if the option is set. _, file_name = tempfile.mkstemp(suffix=".tmp", prefix="jiraerror-") with open(file_name, "w") as f: t += f" details: {file_name}" f.write(details) else: # Otherwise, just return the error as usual if self.text: t += f"\n\ttext: {self.text}" t += f"\n\t{details}" return t jira-3.5.2/jira/jirashell.py000066400000000000000000000267441444726022700157470ustar00rootroot00000000000000"""Starts an interactive Jira session in an ipython terminal. Script arguments support changing the server and a persistent authentication over HTTP BASIC or Kerberos. """ from __future__ import annotations import argparse import configparser import os import sys import webbrowser from getpass import getpass from urllib.parse import parse_qsl import keyring import requests from oauthlib.oauth1 import SIGNATURE_HMAC_SHA1 from requests_oauthlib import OAuth1 from jira import JIRA, __version__ CONFIG_PATH = os.path.join(os.path.expanduser("~"), ".jira-python", "jirashell.ini") SENTINEL = object() 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_HMAC_SHA1, 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.get("oauth_token", SENTINEL) request_token_secret = request.get("oauth_token_secret", SENTINEL) if request_token is SENTINEL or request_token_secret is SENTINEL: problem = request.get("oauth_problem") if problem is not None: message = f"OAuth error: {problem}" else: message = " ".join(f"{key}:{value}" for key, value in request.items()) exit(message) if print_tokens: print("Request tokens received.") print(f" Request token: {request_token}") print(f" Request token secret: {request_token_secret}") # step 2: prompt user to validate auth_url = f"{server}/plugins/servlet/oauth/authorize?oauth_token={request_token}" if print_tokens: print(f"Please visit this URL to authorize the OAuth request:\n\t{auth_url}") else: webbrowser.open_new(auth_url) print( "Your browser is opening the OAuth authorization for this client session." ) approved = input( f"Have you authorized this program to connect on your behalf to {server}? (y/n)" ) 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_HMAC_SHA1, 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(f" Access token: {access['oauth_token']}") print(f" Access token secret: {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(f"Couldn't read config file at path: {CONFIG_PATH}\n{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 = {} if parser.has_section("kerberos_auth"): kerberos_auth = {} for option, value in parser.items("kerberos_auth"): if option in ("use_kerberos"): value = parser.getboolean("kerberos_auth", option) kerberos_auth[option] = value else: kerberos_auth = {} return options, basic_auth, oauth, kerberos_auth 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("--auth-url", help="Path to URL to auth against.") 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." ) kerberos_group = parser.add_argument_group("Kerberos options") kerberos_group.add_argument( "--use-kerberos-auth", action="store_true", help="Use kerberos auth" ) kerberos_group.add_argument( "--mutual-authentication", choices=["OPTIONAL", "DISABLED"], help="Mutual authentication", ) args = parser.parse_args() options = {} if args.server: options["server"] = args.server if args.rest_path: options["rest_path"] = args.rest_path if args.auth_url: options["auth_url"] = args.auth_url 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) 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, } kerberos_auth = {"use_kerberos": args.use_kerberos_auth} if args.mutual_authentication: kerberos_auth["mutual_authentication"] = args.mutual_authentication return options, basic_auth, oauth, kerberos_auth def get_config(): options, basic_auth, oauth, kerberos_auth = process_config() cmd_options, cmd_basic_auth, cmd_oauth, cmd_kerberos_auth = process_command_line() options.update(cmd_options) basic_auth.update(cmd_basic_auth) oauth.update(cmd_oauth) kerberos_auth.update(cmd_kerberos_auth) return options, basic_auth, oauth, kerberos_auth def handle_basic_auth(auth, server): if auth.get("password"): password = auth["password"] if input("Would you like to remember password in OS keyring? (y/n)") == "y": keyring.set_password(server, auth["username"], password) else: print("Getting password from keyring...") password = keyring.get_password(server, auth["username"]) if not password: raise ValueError("No password provided!") return auth["username"], password def main(): try: try: get_ipython except NameError: pass else: sys.exit("Running ipython inside ipython isn't supported. :(") options, basic_auth, oauth, kerberos_auth = get_config() if basic_auth: basic_auth = handle_basic_auth(auth=basic_auth, server=options["server"]) 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 use_kerberos = kerberos_auth.get("use_kerberos", False) del kerberos_auth["use_kerberos"] jira = JIRA( options=options, basic_auth=basic_auth, kerberos=use_kerberos, kerberos_options=kerberos_auth, oauth=oauth, ) import IPython # The top-level `frontend` package has been deprecated since IPython 1.0. if IPython.version_info[0] >= 1: from IPython.terminal.embed import InteractiveShellEmbed else: from IPython.frontend.terminal.embed import InteractiveShellEmbed ip_shell = InteractiveShellEmbed( banner1="" ) ip_shell("*** Jira shell active; client is in 'jira'. Press Ctrl-D to exit.") except Exception as e: print(e, file=sys.stderr) return 2 if __name__ == "__main__": status = main() sys.exit(status) jira-3.5.2/jira/py.typed000066400000000000000000000000001444726022700150700ustar00rootroot00000000000000jira-3.5.2/jira/resilientsession.py000066400000000000000000000330311444726022700173570ustar00rootroot00000000000000from __future__ import annotations import abc import json import logging import random import time from typing import Any from requests import Response, Session from requests.exceptions import ConnectionError from requests.structures import CaseInsensitiveDict from typing_extensions import TypeGuard from jira.exceptions import JIRAError LOG = logging.getLogger(__name__) class PrepareRequestForRetry(metaclass=abc.ABCMeta): """This class allows for the manipulation of the Request keyword arguments before a retry. The :py:meth:`.prepare` handles the processing of the Request keyword arguments. """ @abc.abstractmethod def prepare( self, original_request_kwargs: CaseInsensitiveDict ) -> CaseInsensitiveDict: """Process the Request's keyword arguments before retrying the Request. Args: original_request_kwargs (CaseInsensitiveDict): The keyword arguments of the Request. Returns: CaseInsensitiveDict: The new keyword arguments to use in the retried Request. """ return original_request_kwargs class PassthroughRetryPrepare(PrepareRequestForRetry): """Returns the Request's keyword arguments unchanged, when no change needs to be made before a retry.""" def prepare( self, original_request_kwargs: CaseInsensitiveDict ) -> CaseInsensitiveDict: return super().prepare(original_request_kwargs) def raise_on_error(resp: Response | None, **kwargs) -> TypeGuard[Response]: """Handle errors from a Jira Request. Args: resp (Optional[Response]): Response from Jira request Raises: JIRAError: If Response is None JIRAError: for unhandled 400 status codes. Returns: TypeGuard[Response]: True if the passed in Response is all good. """ request = kwargs.get("request", None) if resp is None: raise JIRAError("Empty Response!", response=resp, **kwargs) if not resp.ok: error = parse_error_msg(resp=resp) raise JIRAError( error, status_code=resp.status_code, url=resp.url, request=request, response=resp, **kwargs, ) return True # if no exception was raised, we have a valid Response def parse_errors(resp: Response) -> list[str]: """Parse a Jira Error messages from the Response. https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/#status-codes Args: resp (Response): The Jira API request's response. Returns: List[str]: The error messages list parsed from the Response. An empty list if no error. """ resp_data: dict[str, Any] = {} # json parsed from the response parsed_errors: list[str] = [] # error messages parsed from the response if resp.status_code == 403 and "x-authentication-denied-reason" in resp.headers: return [resp.headers["x-authentication-denied-reason"]] elif resp.text: try: resp_data = resp.json() except ValueError: return [resp.text] if "message" in resp_data: # Jira 5.1 errors parsed_errors = [resp_data["message"]] elif "errorMessage" in resp_data: # Sometimes Jira returns `errorMessage` as a message error key # for example for the "Service temporary unavailable" error parsed_errors = [resp_data["errorMessage"]] elif "errorMessages" in resp_data: # Jira 5.0.x error messages sometimes come wrapped in this array # Sometimes this is present but empty error_messages = resp_data["errorMessages"] if len(error_messages) > 0: if isinstance(error_messages, (list, tuple)): parsed_errors = list(error_messages) else: parsed_errors = [error_messages] elif "errors" in resp_data: resp_errors = resp_data["errors"] if len(resp_errors) > 0 and isinstance(resp_errors, dict): # Catching only 'errors' that are dict. See https://github.com/pycontribs/jira/issues/350 # Jira 6.x error messages are found in this array. parsed_errors = [str(err) for err in resp_errors.values()] return parsed_errors def parse_error_msg(resp: Response) -> str: """Parse a Jira Error messages from the Response and join them by comma. https://developer.atlassian.com/cloud/jira/platform/rest/v2/intro/#status-codes Args: resp (Response): The Jira API request's response. Returns: str: The error message parsed from the Response. An empty str if no error. """ errors = parse_errors(resp) return ", ".join(errors) class ResilientSession(Session): """This class is supposed to retry requests that do return temporary errors. :py:meth:`__recoverable` handles all retry-able errors. """ def __init__(self, timeout=None, max_retries: int = 3, max_retry_delay: int = 60): """A Session subclass catered for the Jira API with exponential delaying retry. Args: timeout (Optional[Union[Union[float, int], Tuple[float, float]]]): Connection/read timeout delay. Defaults to None. max_retries (int): Max number of times to retry a request. Defaults to 3. max_retry_delay (int): Max delay allowed between retries. Defaults to 60. """ self.timeout = timeout self.max_retries = max_retries self.max_retry_delay = max_retry_delay super().__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"}) # Warn users on instantiation the debug level shouldn't be used for prod LOG.debug( "WARNING: On error, will dump Response headers and body to logs. " + f"Log level debug in '{__name__}' is not safe for production code!" ) def _jira_prepare(self, **original_kwargs) -> dict: """Do any pre-processing of our own and return the updated kwargs.""" prepared_kwargs = original_kwargs.copy() self.headers: CaseInsensitiveDict request_headers = self.headers.copy() request_headers.update(original_kwargs.get("headers", {})) prepared_kwargs["headers"] = request_headers data = original_kwargs.get("data", None) if isinstance(data, dict): # mypy ensures we don't do this, # but for people subclassing we should preserve old behaviour prepared_kwargs["data"] = json.dumps(data) if "verify" not in prepared_kwargs: prepared_kwargs["verify"] = self.verify return prepared_kwargs def request( # type: ignore[override] # An intentionally different override self, method: str, url: str | bytes, _prepare_retry_class: PrepareRequestForRetry = PassthroughRetryPrepare(), **kwargs, ) -> Response: """This is an intentional override of `Session.request()` to inject some error handling and retry logic. Raises: Exception: Various exceptions as defined in py:method:`raise_on_error`. Returns: Response: The response. """ retry_number = 0 exception: Exception | None = None response: Response | None = None response_or_exception: ConnectionError | Response | None processed_kwargs = self._jira_prepare(**kwargs) def is_allowed_to_retry() -> bool: """Helper method to say if we should still be retrying.""" return retry_number <= self.max_retries while is_allowed_to_retry(): response = None exception = None try: response = super().request( method, url, timeout=self.timeout, **processed_kwargs ) if response.ok: self.__handle_known_ok_response_errors(response) return response # Can catch further exceptions as required below except ConnectionError as e: exception = e # Decide if we should keep retrying response_or_exception = response if response is not None else exception retry_number += 1 if is_allowed_to_retry() and self.__recoverable( response_or_exception, url, method.upper(), retry_number ): _prepare_retry_class.prepare(processed_kwargs) # type: ignore[arg-type] # Dict and CaseInsensitiveDict are fine here else: retry_number = self.max_retries + 1 # exit the while loop, as above max if exception is not None: # We got an exception we could not recover from raise exception elif raise_on_error(response, **processed_kwargs): # raise_on_error will raise an exception if the response is invalid return response else: # Shouldn't reach here...(but added for mypy's benefit) raise RuntimeError("Expected a Response or Exception to raise!") def __handle_known_ok_response_errors(self, response: Response): """Responses that report ok may also have errors. We can either log the error or raise the error as appropriate here. Args: response (Response): The response. """ if not response.ok: return # We use self.__recoverable() to handle these if ( len(response.content) == 0 and "X-Seraph-LoginReason" in response.headers and "AUTHENTICATED_FAILED" in response.headers["X-Seraph-LoginReason"] ): LOG.warning("Atlassian's bug https://jira.atlassian.com/browse/JRA-41559") def __recoverable( self, response: ConnectionError | Response | None, url: str | bytes, request_method: str, counter: int = 1, ): """Return whether the request is recoverable and hence should be retried. Exponentially delays if recoverable. At this moment it supports: 429 Args: response (Optional[Union[ConnectionError, Response]]): The response or exception. Note: the response here is expected to be ``not response.ok``. url (Union[str, bytes]): The URL. request_method (str): The request method. counter (int, optional): The retry counter to use when calculating the exponential delay. Defaults to 1. Returns: bool: True if the request should be retried. """ is_recoverable = False # Controls return value AND whether we delay or not, Not-recoverable by default msg = str(response) if isinstance(response, ConnectionError): is_recoverable = True LOG.warning( f"Got ConnectionError [{response}] errno:{response.errno} on {request_method} " + f"{url}\n" # type: ignore[str-bytes-safe] ) if LOG.level > logging.DEBUG: LOG.warning( "Response headers for ConnectionError are only printed for log level DEBUG." ) if isinstance(response, Response): if response.status_code in [429]: is_recoverable = True number_of_tokens_issued_per_interval = response.headers.get( "X-RateLimit-FillRate" ) token_issuing_rate_interval_seconds = response.headers.get( "X-RateLimit-Interval-Seconds" ) maximum_number_of_tokens = response.headers.get("X-RateLimit-Limit") retry_after = response.headers.get("retry-after") msg = f"{response.status_code} {response.reason}" warning_msg = "Request rate limited by Jira." warning_msg += ( f" Request should be retried after {retry_after} seconds.\n" if retry_after is not None else "\n" ) if ( number_of_tokens_issued_per_interval is not None and token_issuing_rate_interval_seconds is not None ): warning_msg += f"{number_of_tokens_issued_per_interval} tokens are issued every {token_issuing_rate_interval_seconds} seconds.\n" if maximum_number_of_tokens is not None: warning_msg += ( f"You can accumulate up to {maximum_number_of_tokens} tokens.\n" ) warning_msg = ( warning_msg + "Consider adding an exemption for the user as explained in: " + "https://confluence.atlassian.com/adminjiraserver/improving-instance-stability-with-rate-limiting-983794911.html" ) LOG.warning(warning_msg) if is_recoverable: # Exponential backoff with full jitter. delay = min(self.max_retry_delay, 10 * 2**counter) * random.random() LOG.warning( f"Got recoverable error from {request_method} {url}, will retry [{counter}/{self.max_retries}] in {delay}s. Err: {msg}" # type: ignore[str-bytes-safe] ) if isinstance(response, Response): LOG.debug( "response.headers:\n%s", json.dumps(dict(response.headers), indent=4), ) LOG.debug("response.body:\n%s", response.content) time.sleep(delay) return is_recoverable jira-3.5.2/jira/resources.py000066400000000000000000001424071444726022700157770ustar00rootroot00000000000000"""Jira resource definitions. This module implements the Resource classes that translate JSON from Jira REST resources into usable objects. """ from __future__ import annotations import json import logging import re import time from typing import TYPE_CHECKING, Any, Dict, List, Type, cast from requests import Response from requests.structures import CaseInsensitiveDict from jira.resilientsession import ResilientSession, parse_errors from jira.utils import json_loads, threaded_requests if TYPE_CHECKING: from jira.client import JIRA AnyLike = Any else: class AnyLike: """Dummy subclass of base object class for when type checker is not running.""" pass __all__ = ( "Resource", "Issue", "Comment", "Project", "Attachment", "Component", "Dashboard", "Filter", "Votes", "PermissionScheme", "Watchers", "Worklog", "IssueLink", "IssueLinkType", "IssueProperty", "IssueSecurityLevelScheme", "IssueType", "IssueTypeScheme", "NotificationScheme", "Priority", "PriorityScheme", "Version", "WorkflowScheme", "Role", "Resolution", "SecurityLevel", "Status", "User", "Group", "CustomFieldOption", "RemoteLink", "Customer", "ServiceDesk", "RequestType", "resource_class_map", ) logging.getLogger("jira").addHandler(logging.NullHandler()) class Resource: """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", "accountId", "filename", "value", "scope", "votes", "id", "mimeType", "closed", ) # A list of properties that should uniquely identify a Resource object. # Each of these properties should be hashable, usually strings _HASH_IDS = ( "self", "type", "key", "id", "name", ) def __init__( self, resource: str, options: dict[str, Any], session: ResilientSession, base_url: str = JIRA_BASE_URL, ): """Initializes a generic resource. Args: resource (str): The name of the resource. options (Dict[str,str]): Options for the new resource session (ResilientSession): Session used for the resource. base_url (Optional[str]): The Base Jira 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: dict[str, Any] | None = None def __str__(self) -> str: """Return the first value we find that is likely to be human-readable. Returns: str """ if self.raw: for name in self._READABLE_IDS: if name in self.raw: pretty_name = str(self.raw[name]) # Include any child to support nested select fields. if hasattr(self, "child"): pretty_name += " - " + str(self.child) return pretty_name # If all else fails, use repr to make sure we get something. return repr(self) def __repr__(self) -> str: """Identify the class and include any and all relevant values. Returns: str """ names: list[str] = [] 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 f"" return f"" def __getattr__(self, item: str) -> Any: """Allow access of attributes via names. Args: item (str): Attribute Name Raises: AttributeError: When attribute does not exist. Returns: Any: Attribute value. """ try: return self[item] # type: ignore except Exception as e: if hasattr(self, "raw") and self.raw is not None and item in self.raw: return self.raw[item] else: raise AttributeError( f"{self.__class__!r} object has no attribute {item!r} ({e})" ) def __getstate__(self) -> dict[str, Any]: """Pickling the resource.""" return vars(self) def __setstate__(self, raw_pickled: dict[str, Any]): """Unpickling of the resource.""" # https://stackoverflow.com/a/50888571/7724187 vars(self).update(raw_pickled) def __hash__(self) -> int: """Hash calculation. We try to find unique identifier like properties to form our hash object. Technically 'self', if present, is the unique URL to the object, and should be sufficient to generate a unique hash. """ hash_list = [] for a in self._HASH_IDS: if hasattr(self, a): hash_list.append(getattr(self, a)) if hash_list: return hash(tuple(hash_list)) else: raise TypeError(f"'{self.__class__}' is not hashable") def __eq__(self, other: Any) -> bool: """Default equality test. Checks the types look about right and that the relevant attributes that uniquely identify a resource are equal. """ return isinstance(other, self.__class__) and all( [ getattr(self, a) == getattr(other, a) for a in self._HASH_IDS if hasattr(self, a) ] ) def find( self, id: tuple[str, str] | int | str, params: dict[str, str] | None = None, ): """Finds a resource based on the input parameters. Args: id (Union[Tuple[str, str], int, str]): id params (Optional[Dict[str, str]]): params """ 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._find_by_url(url, params) def _find_by_url( self, url: str, params: dict[str, str] | None = None, ): """Finds a resource on the specified url. The resource is loaded with the JSON data returned by doing a request on the specified url. Args: url (str): url params (Optional[Dict[str, str]]): params """ self._load(url, params=params) def _get_url(self, path: str) -> str: """Gets the url for the specified path. Args: path (str): str Returns: str """ options = self._options.copy() options.update({"path": path}) return self._base_url.format(**options) def update( self, fields: dict[str, Any] | None = None, async_: bool | None = None, jira: JIRA = None, notify: bool = True, **kwargs: Any, ): """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. Args: fields (Optional[Dict[str, Any]]): Fields which should be updated for the object. async_ (Optional[bool]): True to add the request to the queue, so it can be executed later using async_run() jira (jira.client.JIRA): Instance of Jira Client notify (bool): True to notify watchers about the update, sets parameter notifyUsers. (Default: ``True``). Admin or project admin permissions are required to disable the notification. kwargs (Any): extra arguments to the PUT request. """ if async_ is None: async_: bool = self._options["async"] # type: ignore # redefinition data = {} if fields is not None: data.update(fields) data.update(kwargs) if not notify: querystring = "?notifyUsers=false" else: querystring = "" r = self._session.put(self.self + querystring, data=json.dumps(data)) if "autofix" in self._options and r.status_code == 400: user = None error_list = parse_errors(r) logging.error(error_list) if ( "The reporter specified is not a user." in error_list and "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 and "assignee" not in data["fields"] ): logging.warning( "autofix: setting assignee to '{}' for {} and retrying the update.".format( self._options["autofix"], self.key ) ) data["fields"]["assignee"] = {"name": self._options["autofix"]} 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( r"^User '(.*)' was not found in the system\.", error, re.U ): m = re.search( r"^User '(.*)' was not found in the system\.", error, re.U ) if m: user = m.groups()[0] else: raise NotImplementedError() if re.search(r"^User '(.*)' does not exist\.", error): m = re.search(r"^User '(.*)' does not exist\.", error) if m: user = m.groups()[0] else: raise NotImplementedError() if user and jira: 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 ---> if async_: # FIXME: no async if not hasattr(self._session, "_async_jobs"): self._session._async_jobs = set() # type: ignore self._session._async_jobs.add( # type: ignore threaded_requests.put( # type: ignore self.self, data=json.dumps(data) ) ) else: r = self._session.put(self.self, data=json.dumps(data)) time.sleep(self._options["delay_reload"]) self._load(self.self) def delete(self, params: dict[str, Any] | None = None) -> Response | 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. Args: params: Parameters for the delete request. Returns: Optional[Response]: Returns None if async """ if self._options["async"]: # FIXME: mypy doesn't think this should work if not hasattr(self._session, "_async_jobs"): self._session._async_jobs = set() # type: ignore self._session._async_jobs.add( # type: ignore threaded_requests.delete(url=self.self, params=params) # type: ignore ) return None else: return self._session.delete(url=self.self, params=params) def _load( self, url: str, headers=CaseInsensitiveDict(), params: dict[str, str] | None = None, path: str | None = None, ): """Load a resource. Args: url (str): url headers (Optional[CaseInsensitiveDict]): headers. Defaults to CaseInsensitiveDict(). params (Optional[Dict[str,str]]): params to get request. Defaults to None. path (Optional[str]): field to get. Defaults to None. Raises: ValueError: If json cannot be loaded """ r = self._session.get(url, headers=headers, params=params) try: j = json_loads(r) except ValueError as e: logging.error(f"{e}:\n{r.text}") raise e if path: j = j[path] self._parse_raw(j) def _parse_raw(self, raw: dict[str, Any]): """Parse a raw dictionary to create a resource. Args: raw (Dict[str, Any]) """ self.raw = raw if not raw: raise NotImplementedError(f"We cannot instantiate empty resources: {raw}") dict2resource(raw, self, self._options, self._session) def _default_headers(self, user_headers): # result = dict(user_headers) # result['accept'] = 'application/json' return CaseInsensitiveDict( self._options["headers"].items() + user_headers.items() ) class Attachment(Resource): """An issue attachment.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "attachment/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.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: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "component/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def delete(self, moveIssuesTo: str | None = None): # type: ignore[override] """Delete this component from the server. Args: 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().delete(params) class CustomFieldOption(Resource): """An existing option for a custom issue field.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "customFieldOption/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Dashboard(Resource): """A Jira dashboard.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "dashboard/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Filter(Resource): """An issue navigator filter.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "filter/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Issue(Resource): """A Jira issue.""" class _IssueFields(AnyLike): class _Comment: def __init__(self) -> None: self.comments: list[Comment] = [] class _Worklog: def __init__(self) -> None: self.worklogs: list[Worklog] = [] def __init__(self): self.assignee: UnknownResource | None = None self.attachment: list[Attachment] = [] self.comment = self._Comment() self.created: str self.description: str | None = None self.duedate: str | None = None self.issuelinks: list[IssueLink] = [] self.issuetype: IssueType self.labels: list[str] = [] self.priority: Priority self.project: Project self.reporter: UnknownResource self.resolution: Resolution | None = None self.security: SecurityLevel | None = None self.status: Status self.statuscategorychangedate: str | None = None self.summary: str self.timetracking: TimeTracking self.versions: list[Version] = [] self.votes: Votes self.watchers: Watchers self.worklog = self._Worklog() def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}", options, session) self.fields: Issue._IssueFields self.id: str self.key: str if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def update( # type: ignore[override] # incompatible supertype ignored self, fields: dict[str, Any] = None, update: dict[str, Any] = None, async_: bool = None, jira: JIRA = None, notify: bool = 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 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 Args: fields (Dict[str,Any]): a dict containing field names and the values to use update (Dict[str,Any]): a dict containing update the operations to apply async_ (Optional[bool]): True to add the request to the queue, so it can be executed later using async_run() (Default: ``None``)) jira (Optional[jira.client.JIRA]): JIRA instance. notify (bool): True to notify watchers about the update, sets parameter notifyUsers. (Default: ``True``). Admin or project admin permissions are required to disable the notification. fieldargs (dict): 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, str): if field == "assignee" or field == "reporter": fields_dict[field] = {"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().update(async_=async_, jira=jira, notify=notify, fields=data) def get_field(self, field_name: str) -> Any: """Obtain the (parsed) value from the Issue's field. Args: field_name (str): The name of the field to get Raises: AttributeError: If the field does not exist or if the field starts with an ``_`` Returns: Any: Returns the parsed data stored in the field. For example, "project" would be of class :py:class:`Project` """ if field_name.startswith("_"): raise AttributeError( f"An issue field_name cannot start with underscore (_): {field_name}", field_name, ) else: return getattr(self.fields, field_name) def add_field_value(self, field: str, value: str): """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 Args: field (str): The field name value (str): The field's value """ super().update(fields={"update": {field: [{"add": value}]}}) def delete(self, deleteSubtasks=False): """Delete this issue from the server. Args: deleteSubtasks (bool): True to also delete subtasks. If any are present the Issue won't be deleted (Default: ``True``) """ super().delete(params={"deleteSubtasks": deleteSubtasks}) def permalink(self): """Get the URL of the issue, the browsable one not the REST one. Returns: str: URL of the issue """ return f"{self._options['server']}/browse/{self.key}" class Comment(Resource): """An issue comment.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}/comment/{1}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def update( # type: ignore[override] # The above ignore is added because we've added new parameters and order of # parameters is different. # Will need to be solved in a major version bump. self, fields: dict[str, Any] | None = None, async_: bool | None = None, jira: JIRA = None, body: str = "", visibility: dict[str, str] | None = None, is_internal: bool = False, notify: bool = True, ): """Update a comment. Keyword arguments are marshalled into a dict before being sent. Args: fields (Optional[Dict[str, Any]]): DEPRECATED => a comment doesn't have fields async_ (Optional[bool]): True to add the request to the queue, so it can be executed later using async_run() (Default: ``None``)) jira (jira.client.JIRA): Instance of Jira Client visibility (Optional[Dict[str, str]]): a dict containing two entries: "type" and "value". "type" is 'role' (or 'group' if the Jira server has configured comment visibility for groups) "value" is the name of the role (or group) to which viewing of this comment will be restricted. body (str): New text of the comment is_internal (bool): True to mark the comment as 'Internal' in Jira Service Desk (Default: ``False``) notify (bool): True to notify watchers about the update, sets parameter notifyUsers. (Default: ``True``). Admin or project admin permissions are required to disable the notification. """ data: dict[str, Any] = {} if body: data["body"] = body if visibility: data["visibility"] = visibility if is_internal: data["properties"] = [ {"key": "sd.public.comment", "value": {"internal": is_internal}} ] super().update(async_=async_, jira=jira, notify=notify, fields=data) class RemoteLink(Resource): """A link to a remote application from an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}/remotelink/{1}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.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. Args: object: the link details to add (see the above link for details) globalId: unique ID for the link (see the above link for details) application: application information for the link (see the above link for details) 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().update(**data) class Votes(Resource): """Vote information on an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}/votes", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class IssueTypeScheme(Resource): """An issue type scheme.""" def __init__(self, options, session, raw=None): Resource.__init__(self, "issuetypescheme", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class IssueSecurityLevelScheme(Resource): """IssueSecurityLevelScheme information on a project.""" def __init__(self, options, session, raw=None): Resource.__init__( self, "project/{0}/issuesecuritylevelscheme?expand=user", options, session ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class NotificationScheme(Resource): """NotificationScheme information on a project.""" def __init__(self, options, session, raw=None): Resource.__init__( self, "project/{0}/notificationscheme?expand=user", options, session ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class PermissionScheme(Resource): """Permissionscheme information on a project.""" def __init__(self, options, session, raw=None): Resource.__init__( self, "project/{0}/permissionscheme?expand=user", options, session ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class PriorityScheme(Resource): """PriorityScheme information on a project.""" def __init__(self, options, session, raw=None): Resource.__init__( self, "project/{0}/priorityscheme?expand=user", options, session ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class WorkflowScheme(Resource): """WorkflowScheme information on a project.""" def __init__(self, options, session, raw=None): Resource.__init__( self, "project/{0}/workflowscheme?expand=user", options, session ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Watchers(Resource): """Watcher information on an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}/watchers", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def delete(self, username): """Remove the specified user from the watchers list.""" super().delete(params={"username": username}) class TimeTracking(Resource): def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) self.remainingEstimate = None if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Worklog(Resource): """Worklog on an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}/worklog/{1}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def delete( # type: ignore[override] self, adjustEstimate: str | None = None, newEstimate=None, increaseBy=None ): """Delete this worklog entry from its associated issue. Args: 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. newEstimate: combined with ``adjustEstimate=new``, set the estimate to this value 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().delete(params) class IssueProperty(Resource): """Custom data against an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issue/{0}/properties/{1}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def _find_by_url( self, url: str, params: dict[str, str] | None = None, ): super()._find_by_url(url, params) # An IssueProperty never returns "self" identifier, set it self.self = url class IssueLink(Resource): """Link between two issues.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issueLink/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class IssueLinkType(Resource): """Type of link between two issues.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issueLinkType/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class IssueType(Resource): """Type of issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "issuetype/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Priority(Resource): """Priority that can be set on an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "priority/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Project(Resource): """A Jira project.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "project/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Role(Resource): """A role inside a project.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "project/{0}/role/{1}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def update( # type: ignore[override] self, users: str | list | tuple = None, groups: str | list | tuple = None, ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. Args: users (Optional[Union[str,List,Tuple]]): a user or users to add to the role groups (Optional[Union[str,List,Tuple]]): a group or groups to add to the role """ if users is not None and isinstance(users, str): users = (users,) if groups is not None and isinstance(groups, str): groups = (groups,) data = { "id": self.id, "categorisedActors": { "atlassian-user-role-actor": users, "atlassian-group-role-actor": groups, }, } super().update(**data) def add_user( self, users: str | list | tuple = None, groups: str | list | tuple = None, ): """Add the specified users or groups to this project role. One of ``users`` or ``groups`` must be specified. Args: users (Optional[Union[str,List,Tuple]]): a user or users to add to the role groups (Optional[Union[str,List,Tuple]]): a group or groups to add to the role """ if users is not None and isinstance(users, str): users = (users,) if groups is not None and isinstance(groups, str): groups = (groups,) data = {"user": users, "group": groups} self._session.post(self.self, data=json.dumps(data)) class Resolution(Resource): """A resolution for an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "resolution/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class SecurityLevel(Resource): """A security level for an issue or project.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "securitylevel/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Status(Resource): """Status for an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "status/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class StatusCategory(Resource): """StatusCategory for an issue.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "statuscategory/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class User(Resource): """A Jira user.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, *, _query_param: str = "username", ): # Handle self-hosted Jira and Jira Cloud differently if raw and "accountId" in raw["self"]: _query_param = "accountId" Resource.__init__(self, f"user?{_query_param}" + "={0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Group(Resource): """A Jira user group.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "group?groupname={0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Version(Resource): """A version of a project.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "version/{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.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. Args: moveFixIssuesTo: in issues for which this version is a fix version, add this version to the fix version list moveAffectedIssuesTo: in issues for which this version is an affected version, add this 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().delete(params) def update(self, **kwargs): """Update this project version from the server. It is prior used to archive versions. Refer to Atlassian REST API `documentation`_. .. _documentation: https://developer.atlassian.com/cloud/jira/platform/rest/v2/api-group-project-versions/#api-rest-api-2-version-id-put :Example: .. code-block:: python >> version_id = "10543" >> version = JIRA("https://atlassian.org").version(version_id) >> print(version.name) "some_version_name" >> version.update(name="another_name") >> print(version.name) "another_name" >> version.update(archived=True) >> print(version.archived) True """ data = {} for field in kwargs: data[field] = kwargs[field] super().update(**data) # Agile class AgileResource(Resource): """A generic Agile resource. Also known as Jira Agile Server, Jira Software and formerly GreenHopper.""" AGILE_BASE_URL = "{server}/rest/{agile_rest_path}/{agile_rest_api_version}/{path}" AGILE_BASE_REST_PATH = "agile" """Public API introduced in Jira Agile 6.7.7.""" def __init__( self, path: str, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): self.self = None Resource.__init__(self, path, options, session, self.AGILE_BASE_URL) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class Sprint(AgileResource): """An Agile sprint.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): AgileResource.__init__(self, "sprint/{0}", options, session, raw) class Board(AgileResource): """An Agile board.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): AgileResource.__init__(self, "board/{id}", options, session, raw) # Service Desk class Customer(Resource): """A Service Desk customer.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__( self, "customer", options, session, "{server}/rest/servicedeskapi/{path}" ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class ServiceDesk(Resource): """A Service Desk.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__( self, "servicedesk/{0}", options, session, "{server}/rest/servicedeskapi/{path}", ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) class RequestType(Resource): """A Service Desk Request Type.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__( self, "servicedesk/{0}/requesttype", options, session, "{server}/rest/servicedeskapi/{path}", ) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) # Utilities def dict2resource( raw: dict[str, Any], top=None, options=None, session=None ) -> PropertyHolder | type[Resource]: """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 = PropertyHolder() seqs = tuple, list, set, frozenset for i, j in raw.items(): if isinstance(j, dict): if "self" in j: # to try and help mypy know that cls_for_resource can never be 'Resource' resource_class = cast(Type[Resource], cls_for_resource(j["self"])) resource = cast( Type[Resource], resource_class( # type: ignore options=options, session=session, raw=j # type: ignore ), ) 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): j = cast(List[Dict[str, Any]], j) # help mypy seq_list: list[Any] = [] for seq_elem in j: if isinstance(seq_elem, dict): if "self" in seq_elem: # to try and help mypy know that cls_for_resource can never be 'Resource' resource_class = cast( Type[Resource], cls_for_resource(seq_elem["self"]) ) resource = cast( Type[Resource], resource_class( # type: ignore options=options, session=session, raw=seq_elem, # type: ignore ), ) 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: dict[str, type[Resource]] = { # 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"issue/[^/]+/properties/[^/]+$": IssueProperty, r"issueLink/[^/]+$": IssueLink, r"issueLinkType/[^/]+$": IssueLinkType, r"issuetype/[^/]+$": IssueType, r"issuetypescheme/[^/]+$": IssueTypeScheme, r"project/[^/]+/issuesecuritylevelscheme[^/]+$": IssueSecurityLevelScheme, r"project/[^/]+/notificationscheme[^/]+$": NotificationScheme, r"project/[^/]+/priorityscheme[^/]+$": PriorityScheme, r"priority/[^/]+$": Priority, r"project/[^/]+$": Project, r"project/[^/]+/role/[^/]+$": Role, r"project/[^/]+/permissionscheme[^/]+$": PermissionScheme, r"project/[^/]+/workflowscheme[^/]+$": WorkflowScheme, r"resolution/[^/]+$": Resolution, r"securitylevel/[^/]+$": SecurityLevel, r"status/[^/]+$": Status, r"statuscategory/[^/]+$": StatusCategory, r"user\?(username|key|accountId).+$": User, r"group\?groupname.+$": Group, r"version/[^/]+$": Version, # Agile specific resources r"sprints/[^/]+$": Sprint, r"views/[^/]+$": Board, } class UnknownResource(Resource): """A Resource from Jira that is not (yet) supported.""" def __init__( self, options: dict[str, str], session: ResilientSession, raw: dict[str, Any] = None, ): Resource.__init__(self, "unknown{0}", options, session) if raw: self._parse_raw(raw) self.raw: dict[str, Any] = cast(Dict[str, Any], self.raw) def cls_for_resource(resource_literal: str) -> type[Resource]: for resource in resource_class_map: if re.search(resource, resource_literal): return resource_class_map[resource] else: # Generic Resource cannot directly be used b/c of different constructor signature return UnknownResource class PropertyHolder: """An object for storing named attributes.""" jira-3.5.2/jira/utils/000077500000000000000000000000001444726022700145435ustar00rootroot00000000000000jira-3.5.2/jira/utils/LICENSE000066400000000000000000000030201444726022700155430ustar00rootroot00000000000000Copyright (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-3.5.2/jira/utils/__init__.py000066400000000000000000000047541444726022700166660ustar00rootroot00000000000000"""Jira utils used internally.""" from __future__ import annotations import threading import warnings from typing import Any, cast from requests import Response from requests.structures import CaseInsensitiveDict as _CaseInsensitiveDict from jira.resilientsession import raise_on_error class CaseInsensitiveDict(_CaseInsensitiveDict): """A case-insensitive ``dict``-like object. DEPRECATED: use requests.structures.CaseInsensitiveDict directly. 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()`` 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, **kwargs) -> None: warnings.warn( "Use requests.structures.CaseInsensitiveDict directly", DeprecationWarning ) super().__init__(*args, **kwargs) 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(resp: Response | None) -> Any: """Attempts to load json the result of a response. Args: resp (Optional[Response]): The Response object Raises: JIRAError: via :py:func:`jira.resilientsession.raise_on_error` Returns: Union[List[Dict[str, Any]], Dict[str, Any]]: the json """ raise_on_error(resp) # if 'resp' is None, will raise an error here resp = cast(Response, resp) # tell mypy only Response-like are here try: return resp.json() except ValueError: # json.loads() fails with empty bodies if not resp.text: return {} raise jira-3.5.2/make_local_jira_user.py000066400000000000000000000032761444726022700171720ustar00rootroot00000000000000"""Attempts to create a test user, as the empty JIRA instance isn't provisioned with one.""" from __future__ import annotations import sys import time from os import environ import requests from jira import JIRA CI_JIRA_URL = environ["CI_JIRA_URL"] def add_user_to_jira(): try: JIRA( CI_JIRA_URL, basic_auth=(environ["CI_JIRA_ADMIN"], environ["CI_JIRA_ADMIN_PASSWORD"]), ).add_user( username=environ["CI_JIRA_USER"], email="user@example.com", fullname=environ["CI_JIRA_USER_FULL_NAME"], password=environ["CI_JIRA_USER_PASSWORD"], ) print("user", environ["CI_JIRA_USER"]) except Exception as e: if "username already exists" not in str(e): raise e if __name__ == "__main__": if environ.get("CI_JIRA_TYPE", "Server").upper() == "CLOUD": print("Do not need to create a user for Jira Cloud CI, quitting.") sys.exit() start_time = time.time() timeout_mins = 15 print( "waiting for instance of jira to be running, to add a user for CI system:\n" f" timeout = {timeout_mins} mins" ) while True: try: requests.get(CI_JIRA_URL + "rest/api/2/permissions") print("JIRA IS REACHABLE") add_user_to_jira() break except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as ex: print(f"encountered {ex} while waiting for the JiraServer docker") time.sleep(20) if start_time + 60 * timeout_mins < time.time(): raise TimeoutError( f"Jira server wasn't reachable within timeout {timeout_mins}" ) jira-3.5.2/pyproject.toml000066400000000000000000000037011444726022700153730ustar00rootroot00000000000000[build-system] requires = ["setuptools >= 60.0.0", "setuptools_scm[toml] >= 7.0.0"] build-backend = "setuptools.build_meta" # Setuptools config # Equivalent to use_scm_version=True [tool.setuptools_scm] [tool.pytest.ini_options] minversion = "6.0" testpaths = ["tests"] python_files = ["test_*.py", "tests.py"] addopts = '''-p no:xdist --durations=10 --tb=long -rxX -v --color=yes --junitxml=build/results.xml --cov-report=xml --cov jira''' # these are important for distributed testing, to speedup their execution we minimize what we sync rsyncdirs = ". jira demo docs" rsyncignore = ".git" # pytest-timeout, delete_project on jira cloud takes >70s timeout = 80 # avoid useless warnings related to coverage skips filterwarnings = ["ignore::pytest.PytestWarning"] markers = ["allow_on_cloud: opt in for the test to run on Jira Cloud"] [tool.mypy] python_version = 3.8 warn_unused_configs = true namespace_packages = true # check_untyped_defs = true [tool.ruff] select = [ "E", # pydocstyle "W", # pydocstyle "F", # pyflakes "I", # isort "UP", # pyupgrade "D", # docstrings ] ignore = [ "E501", # We have way too many "line too long" errors at the moment # TODO: Address these with time "D100", "D101", "D102", "D103", "D105", "D107", "D401", "D402", "D417", ] # Same as Black. line-length = 88 # Allow unused variables when underscore-prefixed. dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" # Assume Python 3.8. (minimum supported) target-version = "py38" # The source code paths to consider, e.g., when resolving first- vs. third-party imports src = ["jira", "tests"] [tool.ruff.isort] known-first-party = ["jira", "tests"] required-imports = ["from __future__ import annotations"] [tool.ruff.per-file-ignores] "jira/__init__.py" = [ "E402", # ignore import order in this file ] [tool.ruff.pydocstyle] # Use Google-style docstrings. convention = "google" jira-3.5.2/setup.cfg000066400000000000000000000053541444726022700143060ustar00rootroot00000000000000[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. long_description = file: README.rst # Do not include ChangeLog in description-file due to multiple reasons: # - Unicode chars, see https://github.com/pycontribs/jira/issues/512 # - Breaks ability to perform `python setup.py install` long_description_content_type = text/x-rst; charset=UTF-8 url = https://github.com/pycontribs/jira project_urls = Bug Tracker = https://github.com/pycontribs/jira/issues Release Management = https://github.com/pycontribs/jira/projects CI: GitHub Actions = https://github.com/pycontribs/jira/actions Source Code = https://github.com/pycontribs/jira.git Documentation = https://jira.readthedocs.io Forum = https://community.atlassian.com/t5/tag/jira-python/tg-p?sort=recent requires_python = >=3.8 platforms = any license = BSD-2-Clause classifiers = 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 :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 Topic :: Software Development :: Libraries :: Python Modules Topic :: Internet :: WWW/HTTP keywords = api, atlassian, jira, rest, web [files] packages = jira [options] python_requires = >=3.8 packages = find: include_package_data = True zip_safe = False install_requires = defusedxml packaging requests-oauthlib>=1.1.0 requests>=2.10.0 requests_toolbelt typing_extensions>=3.7.4.2 [options.extras_require] cli = ipython>=4.0.0 keyring docs = sphinx>=5.0.0 sphinx-copybutton # HTML Theme furo opt = filemagic>=1.6 PyJWT requests_jwt requests_kerberos async = requests-futures>=0.9.7 test = docutils>=0.12 flaky MarkupSafe>=0.23 oauthlib pytest-cache pytest-cov pytest-instafail pytest-sugar pytest-timeout>=1.3.1 pytest-xdist>=2.2 pytest>=6.0.0 # MIT PyYAML>=5.1 # MIT requests_mock # Apache-2 requires.io # UNKNOWN!!! tenacity # Apache-2 wheel>=0.24.0 # MIT xmlrunner>=1.7.7 # LGPL yanc>=0.3.3 # GPL parameterized>=0.8.1 # BSD-3-Clause [options.entry_points] console_scripts = jirashell = jira.jirashell:main [options.package_data] jira = jira/py.typed jira-3.5.2/tests/000077500000000000000000000000001444726022700136205ustar00rootroot00000000000000jira-3.5.2/tests/__init__.py000066400000000000000000000003321444726022700157270ustar00rootroot00000000000000"""All the tests for the jira package. Refer to conftest.py for shared helper methods. resources/test_* : For tests related to resources test_* : For other tests of the non-resource elements of the jira package. """ jira-3.5.2/tests/conftest.py000066400000000000000000000304121444726022700160170ustar00rootroot00000000000000from __future__ import annotations import getpass import hashlib import logging import os import random import re import string import sys import unittest from time import sleep from typing import Any import pytest from jira import JIRA from jira.exceptions import JIRAError 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") LOGGER = logging.getLogger(__name__) allow_on_cloud = pytest.mark.allow_on_cloud broken_test = pytest.mark.xfail class JiraTestCase(unittest.TestCase): """Test case for all Jira tests. This is the base class for all Jira tests that require access to the Jira instance. It calls JiraTestManager() in the setUp() method. setUp() is the method that is called **before** each test is run. Where possible follow the: * GIVEN - where you set up any pre-requisites e.g. the expected result * WHEN - where you perform the action and obtain the result * THEN - where you assert the expectation vs the result format for tests. """ jira: JIRA # admin authenticated jira_normal: JIRA # non-admin authenticated def setUp(self) -> None: """ This is called before each test. If you want to add more for your tests, Run `super().setUp() in your custom setUp() to obtain these. """ initialized = False try: self.test_manager = JiraTestManager() initialized = self.test_manager.initialized except Exception as e: # pytest with flaky swallows any exceptions re-raised in a try, except # so we log any exceptions for aiding debugging LOGGER.exception(e) self.assertTrue(initialized, "Test Manager setUp failed") self.jira = self.test_manager.jira_admin self.jira_normal = self.test_manager.jira_normal self.user_admin = self.test_manager.user_admin self.user_normal = self.test_manager.user_normal # use this user where possible self.project_b = self.test_manager.project_b self.project_a = self.test_manager.project_a @property def identifying_user_property(self) -> str: """Literal["accountId", "name"]: Depending on if Jira Cloud or Server""" return "accountId" if self.is_jira_cloud_ci else "name" @property def is_jira_cloud_ci(self) -> bool: """is running on Jira Cloud""" return self.test_manager._cloud_ci def rndstr(): return "".join(random.sample(string.ascii_lowercase, 6)) def rndpassword(): # generates a password of length 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.sha256(some_string.encode("utf-8")).hexdigest()[:8].upper() def get_unique_project_name(): user = re.sub("[^A-Z_]", "", getpass.getuser().upper()) if "GITHUB_ACTION" in os.environ and "GITHUB_RUN_NUMBER" in os.environ: # please note that user underline (_) is not supported by # Jira even if it is documented as supported. return "GH" + hashify(user + os.environ["GITHUB_RUN_NUMBER"]) identifier = ( user + chr(ord("A") + sys.version_info[0]) + chr(ord("A") + sys.version_info[1]) ) return "Z" + hashify(identifier) class JiraTestManager: """Instantiate and populate the JIRA instance with data for 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. initialized (bool): if init was successful. """ __shared_state: dict[Any, Any] = {} def __init__(self, jira_hosted_type=os.environ.get("CI_JIRA_TYPE", "Server")): """Instantiate and populate the JIRA instance""" self.__dict__ = self.__shared_state if not self.__dict__: self.initialized = False self.max_retries = 5 self._cloud_ci = False if jira_hosted_type and jira_hosted_type.upper() == "CLOUD": self.set_jira_cloud_details() self._cloud_ci = True else: self.set_jira_server_details() jira_class_kwargs = { "server": self.CI_JIRA_URL, "logging": False, "validate": True, "max_retries": self.max_retries, } self.set_basic_auth_logins(**jira_class_kwargs) if not self.jira_admin.current_user(): self.initialized = True sys.exit(3) # now we need to create some data to start with for the tests self.create_some_data() if not hasattr(self, "jira_normal") or not hasattr(self, "jira_admin"): pytest.exit("FATAL: WTF!?") if self._cloud_ci: self.user_admin = self.jira_admin.search_users(query=self.CI_JIRA_ADMIN)[0] self.user_normal = self.jira_admin.search_users(query=self.CI_JIRA_USER)[0] else: self.user_admin = self.jira_admin.search_users(self.CI_JIRA_ADMIN)[0] self.user_normal = self.jira_admin.search_users(self.CI_JIRA_USER)[0] self.initialized = True def set_jira_cloud_details(self): self.CI_JIRA_URL = "https://pycontribs.atlassian.net" self.CI_JIRA_ADMIN = os.environ["CI_JIRA_CLOUD_ADMIN"] self.CI_JIRA_ADMIN_PASSWORD = os.environ["CI_JIRA_CLOUD_ADMIN_TOKEN"] self.CI_JIRA_USER = os.environ["CI_JIRA_CLOUD_USER"] self.CI_JIRA_USER_PASSWORD = os.environ["CI_JIRA_CLOUD_USER_TOKEN"] self.CI_JIRA_ISSUE = os.environ.get("CI_JIRA_ISSUE", "Bug") def set_jira_server_details(self): self.CI_JIRA_URL = os.environ["CI_JIRA_URL"] self.CI_JIRA_ADMIN = os.environ["CI_JIRA_ADMIN"] self.CI_JIRA_ADMIN_PASSWORD = os.environ["CI_JIRA_ADMIN_PASSWORD"] self.CI_JIRA_USER = os.environ["CI_JIRA_USER"] self.CI_JIRA_USER_PASSWORD = os.environ["CI_JIRA_USER_PASSWORD"] self.CI_JIRA_ISSUE = os.environ.get("CI_JIRA_ISSUE", "Bug") def set_basic_auth_logins(self, **jira_class_kwargs): if self.CI_JIRA_ADMIN: self.jira_admin = JIRA( basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), **jira_class_kwargs, ) self.jira_sysadmin = JIRA( basic_auth=(self.CI_JIRA_ADMIN, self.CI_JIRA_ADMIN_PASSWORD), **jira_class_kwargs, ) self.jira_normal = JIRA( basic_auth=(self.CI_JIRA_USER, self.CI_JIRA_USER_PASSWORD), **jira_class_kwargs, ) else: raise RuntimeError("CI_JIRA_ADMIN environment variable is not set/empty.") def _project_exists(self, project_key: str) -> bool: """True if we think the project exists, else False. Assumes project exists if unknown Jira exception is raised. """ try: self.jira_admin.project(project_key) except JIRAError as e: # If the project does not exist a warning is thrown if "No project could be found" in str(e): return False LOGGER.exception("Assuming project '%s' exists.", project_key) return True def _remove_project(self, project_key): """Ensure if the project exists we delete it first""" wait_between_checks_secs = 2 time_to_wait_for_delete_secs = 40 wait_attempts = int(time_to_wait_for_delete_secs / wait_between_checks_secs) # TODO(ssbarnea): find a way to prevent SecurityTokenMissing for On Demand # https://jira.atlassian.com/browse/JRA-39153 if self._project_exists(project_key): try: self.jira_admin.delete_project(project_key) except Exception: LOGGER.exception("Failed to delete '%s'.", project_key) # wait for the project to be deleted for _ in range(1, wait_attempts): if not self._project_exists(project_key): # If the project does not exist a warning is thrown # so once this is raised we know it is deleted successfully break sleep(wait_between_checks_secs) if self._project_exists(project_key): raise TimeoutError( " Project '{project_key}' not deleted after {time_to_wait_for_delete_secs} seconds" ) def _create_project( self, project_key: str, project_name: str, force_recreate: bool = False ) -> int: """Create a project and return the id""" if not force_recreate and self._project_exists(project_key): pass else: self._remove_project(project_key) create_attempts = 6 for _ in range(create_attempts): try: if self.jira_admin.create_project(project_key, project_name): break except JIRAError as e: if "A project with that name already exists" not in str(e): raise e return self.jira_admin.project(project_key).id def create_some_data(self): """Create some data 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 concurrency 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. """ self.jid = get_unique_project_name() self.project_a = self.jid + "A" # old XSS self.project_a_name = f"Test user={getpass.getuser()} key={self.project_a} A" self.project_b = self.jid + "B" # old BULK self.project_b_name = f"Test user={getpass.getuser()} key={self.project_b} B" self.project_sd = self.jid + "C" self.project_sd_name = f"Test user={getpass.getuser()} key={self.project_sd} C" self.project_a_id = self._create_project(self.project_a, self.project_a_name) self.project_b_id = self._create_project( self.project_b, self.project_b_name, force_recreate=True ) sleep(1) # keep it here as often Jira will report the # project as missing even after is created project_b_issue_kwargs = { "project": self.project_b, "issuetype": {"name": self.CI_JIRA_ISSUE}, } self.project_b_issue1_obj = self.jira_admin.create_issue( summary=f"issue 1 from {self.project_b}", **project_b_issue_kwargs ) self.project_b_issue1 = self.project_b_issue1_obj.key self.project_b_issue2_obj = self.jira_admin.create_issue( summary=f"issue 2 from {self.project_b}", **project_b_issue_kwargs ) self.project_b_issue2 = self.project_b_issue2_obj.key self.project_b_issue3_obj = self.jira_admin.create_issue( summary=f"issue 3 from {self.project_b}", **project_b_issue_kwargs ) self.project_b_issue3 = self.project_b_issue3_obj.key 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 @pytest.fixture() def no_fields(monkeypatch): """When we want to test the __init__ method of the jira.client.JIRA we don't need any external calls to get the fields. We don't need the features of a MagicMock, hence we don't use it here. """ monkeypatch.setattr(JIRA, "fields", lambda *args, **kwargs: []) jira-3.5.2/tests/icon.png000066400000000000000000000315031444726022700152600ustar00rootroot00000000000000PNG  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-3.5.2/tests/id_rsa000066400000000000000000000062531444726022700150120ustar00rootroot00000000000000-----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-3.5.2/tests/id_rsa.pub000066400000000000000000000013541444726022700155740ustar00rootroot00000000000000ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDqowm5fbvmcAZUTO6vBRCwHeBcyZUpPCJ6Szay57cOQLasSkAnk46Yv8ZZpHy66IGpYO1oAItkiwuhMOG+ThGGfIhb1Emk2pQ4y2hmpJ6zTWUXsNRtTcxb+Ekre49g652/X0wMiR9tev55++qBPjIVtnlzLNVFLFT6Zd9IryFo78fBTWgmuSf6ttmdSGoz9X+chqRYW+SVR0zsdu0XorTWuGdZnynvCO2xyVMskaqh+RUahHzlZ5cje5s8WyIYH/CtLvhnfo85rM2xpAEod6u6jOPPsTCgeOiPFqvB32s98kBUiJum4Ds0jTrU14nyfR0S9yWo8lLuIJI4VmNy94ox89PiPT40wNdWm0NCw2VCNlEGTIdHCQ7UCBFBUDJFMCtyxuFQrWZc4YcwAZ4vuujFZy7DT1BmvbsuyaSAxAvDG1ZNfRGMomzj/4hbK66c2FQu2JS7CUjmbOgCLOS6IHEJpkzHgTQT4wVtaWAvpEF1nwMTeesmq1dSAhwfXHFT1JgPlrdqvvqhOxEOrAlaciu8oulfIjJhrnVbq2vXGLTnYAp/FmC2FQPDbMIBv7ZCXxPhMUc7BlyDVjz6BRMM13TqKmV3vYU4+vHlby4RDBJ3zhVua7gxU46m5K8D5WlclKS3VD33i4b2yy7kkR52VRgA3LAkmFqvToY59PsTWjf8+w== pycontribs@example.com jira-3.5.2/tests/resources/000077500000000000000000000000001444726022700156325ustar00rootroot00000000000000jira-3.5.2/tests/resources/__init__.py000066400000000000000000000002261444726022700177430ustar00rootroot00000000000000"""Tests grouped by Resource type The resources/ folder contains tests grouped per jira.resource.Resource with files for each of its subclasses. """ jira-3.5.2/tests/resources/test_attachment.py000066400000000000000000000045641444726022700214040ustar00rootroot00000000000000from __future__ import annotations import os from pathlib import Path from tests.conftest import TEST_ATTACH_PATH, JiraTestCase class AttachmentTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) 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"]) # we have no control over server side upload limit self.assertIn("uploadLimit", meta) def test_1_add_remove_attachment_using_filestream(self): issue = self.jira.issue(self.issue_1) with open(TEST_ATTACH_PATH, "rb") as f: attachment = self.jira.add_attachment(issue, f, "new test attachment") new_attachment = self.jira.attachment(attachment.id) msg = f"attachment {new_attachment.__dict__} of issue {issue}" self.assertEqual(new_attachment.filename, "new test attachment", msg=msg) self.assertEqual( new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg ) # JIRA returns a HTTP 204 upon successful deletion self.assertEqual(attachment.delete().status_code, 204) def test_attach_with_no_filename(self): issue = self.jira.issue(self.issue_1) attachment_no_filename_specified = self.jira.add_attachment( issue=issue.key, attachment=TEST_ATTACH_PATH, filename=None ) new_attachment = self.jira.attachment(attachment_no_filename_specified.id) msg = f"attachment, no filename specified {new_attachment.__dict__} of issue {issue}" assert new_attachment.filename == Path(TEST_ATTACH_PATH).name, msg def test_2_add_remove_attachment_using_filename(self): issue = self.jira.issue(self.issue_1) attachment = self.jira.add_attachment( issue, TEST_ATTACH_PATH, "new test attachment" ) new_attachment = self.jira.attachment(attachment.id) msg = f"attachment {new_attachment.__dict__} of issue {issue}" self.assertEqual(new_attachment.filename, "new test attachment", msg=msg) self.assertEqual( new_attachment.size, os.path.getsize(TEST_ATTACH_PATH), msg=msg ) # JIRA returns a HTTP 204 upon successful deletion self.assertEqual(attachment.delete().status_code, 204) jira-3.5.2/tests/resources/test_board.py000066400000000000000000000027611444726022700203400ustar00rootroot00000000000000from __future__ import annotations from contextlib import contextmanager from typing import Iterator from jira.resources import Board from tests.conftest import JiraTestCase, rndstr class BoardTests(JiraTestCase): def setUp(self): super().setUp() 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 uniq = rndstr() self.board_name = "board-" + uniq self.filter_name = "filter-" + uniq self.filter = self.jira.create_filter( self.filter_name, "description", f"project={self.project_b}", True ) def tearDown(self) -> None: self.filter.delete() super().tearDown() @contextmanager def _create_board(self) -> Iterator[Board]: """Helper method to create a Board.""" board = None try: board = self.jira.create_board( name=self.board_name, filter_id=self.filter.id, project_ids=self.project_b, ) yield board finally: if board is not None: board.delete() def test_create_and_delete(self): # GIVEN: The filter # WHEN: we create a board with self._create_board() as board: # THEN: We get a reasonable looking board assert isinstance(board.id, int) # THEN: the board.delete() method is called successfully jira-3.5.2/tests/resources/test_comment.py000066400000000000000000000066201444726022700207110ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class CommentTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_1_key = self.test_manager.project_b_issue1 self.issue_2_key = self.test_manager.project_b_issue2 self.issue_3_key = self.test_manager.project_b_issue3 def tearDown(self) -> None: for issue in [self.issue_1_key, self.issue_2_key, self.issue_3_key]: for comment in self.jira.comments(issue): comment.delete() def test_comments(self): for issue in [self.issue_1_key, self.jira.issue(self.issue_2_key)]: 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_expanded_comments(self): comment1 = self.jira.add_comment(self.issue_1_key, "First comment") comment2 = self.jira.add_comment(self.issue_1_key, "Second comment") comments = self.jira.comments(self.issue_1_key, expand="renderedBody") self.assertTrue(hasattr(comments[0], "renderedBody")) ret_comment1 = self.jira.comment( self.issue_1_key, comment1.id, expand="renderedBody" ) ret_comment2 = self.jira.comment(self.issue_1_key, comment2.id) comment1.delete() comment2.delete() self.assertTrue(hasattr(ret_comment1, "renderedBody")) self.assertFalse(hasattr(ret_comment2, "renderedBody")) comments = self.jira.comments(self.issue_1_key) assert len(comments) == 0 def test_add_comment(self): comment = self.jira.add_comment( self.issue_3_key, "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_key) 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_key, "updating soon!") comment.update(body="updated!") assert comment.body == "updated!" # self.assertEqual(comment.visibility.type, 'role') # self.assertEqual(comment.visibility.value, 'Administrators') comment.delete() def test_update_comment_with_notify(self): comment = self.jira.add_comment(self.issue_3_key, "updating soon!") comment.update(body="updated! without notification", notify=False) assert comment.body == "updated! without notification" comment.delete() jira-3.5.2/tests/resources/test_component.py000066400000000000000000000063351444726022700212540ustar00rootroot00000000000000from __future__ import annotations from jira.exceptions import JIRAError from tests.conftest import JiraTestCase, rndstr class ComponentTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) 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 = f"project-{proj}-component-{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.jira.current_user(), ) name = "renamed-" + name component.update( name=name, description="It is done.", leadUserName=self.jira.current_user() ) self.assertEqual(component.name, name) self.assertEqual(component.description, "It is done.") self.assertEqual(component.lead.name, self.jira.current_user()) 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) def test_delete_component_by_id(self): component = self.jira.create_component( "To be deleted", self.project_b, description="not long for this world" ) myid = component.id self.jira.delete_component(myid) self.assertRaises(JIRAError, self.jira.component, myid) jira-3.5.2/tests/resources/test_custom_field_option.py000066400000000000000000000004121444726022700233050ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class CustomFieldOptionTests(JiraTestCase): def test_custom_field_option(self): option = self.jira.custom_field_option("10000") self.assertEqual(option.value, "To Do") jira-3.5.2/tests/resources/test_customer.py000066400000000000000000000000001444726022700210720ustar00rootroot00000000000000jira-3.5.2/tests/resources/test_dashboard.py000066400000000000000000000021131444726022700211670ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase, broken_test class DashboardTests(JiraTestCase): def test_dashboards(self): dashboards = self.jira.dashboards() self.assertGreaterEqual(len(dashboards), 1) @broken_test( reason="standalone jira docker image has only 1 system dashboard by default" ) 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=0, 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): expected_ds = self.jira.dashboards()[0] dashboard = self.jira.dashboard(expected_ds.id) self.assertEqual(dashboard.id, expected_ds.id) self.assertEqual(dashboard.name, expected_ds.name) jira-3.5.2/tests/resources/test_epic.py000066400000000000000000000042151444726022700201650ustar00rootroot00000000000000from __future__ import annotations from contextlib import contextmanager from functools import cached_property from typing import Iterator from parameterized import parameterized from jira.resources import Issue from tests.conftest import JiraTestCase, rndstr class EpicTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_1 = self.test_manager.project_b_issue1 self.issue_2 = self.test_manager.project_b_issue2 self.epic_name: str = "My epic " + rndstr() @cached_property def epic_field_name(self): """The 'Epic Name' Custom field number. `customfield_X`.""" field_name = "Epic Name" # Only Jira Server has a separate endpoint for custom fields! # Jira Cloud gets them automatically with self.jira.fields() custom_fields_json = self.jira._get_json("customFields") all_custom_fields = custom_fields_json["values"] epic_name_cf = [c for c in all_custom_fields if c["name"] == field_name][0] return f"customfield_{epic_name_cf['numericId']}" @contextmanager def make_epic(self, **kwargs) -> Iterator[Issue]: try: # TODO: create_epic() method should exist! new_epic = self.jira.create_issue( fields={ "issuetype": {"name": "Epic"}, "project": self.project_b, self.epic_field_name: self.epic_name, "summary": f"Epic summary for '{self.epic_name}'", }, ) if len(kwargs): raise ValueError("Incorrect kwarg used !") yield new_epic finally: new_epic.delete() def test_epic_create_delete(self): with self.make_epic(): pass @parameterized.expand( [("str", str), ("list", list)], ) def test_add_issues_to_epic(self, name: str, input_type): issue_list = [self.issue_1, self.issue_2] with self.make_epic() as new_epic: self.jira.add_issues_to_epic( new_epic.id, ",".join(issue_list) if input_type == str else issue_list, ) jira-3.5.2/tests/resources/test_filter.py000066400000000000000000000053651444726022700205410ustar00rootroot00000000000000from __future__ import annotations from contextlib import contextmanager from tests.conftest import JiraTestCase, rndstr class FilterTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_1 = self.test_manager.project_b_issue1 self.issue_2 = self.test_manager.project_b_issue2 self.filter_jql: str = f"project = {self.project_b} AND component is not EMPTY" self.filter_name: str = "some filter " + rndstr() self.filter_desc: str = "just some new test filter" self.filter_favourite: bool | None = False @contextmanager def make_filter(self, **kwargs): try: new_filter = self.jira.create_filter( name=kwargs.pop("name", self.filter_name), description=kwargs.pop("description", self.filter_desc), jql=kwargs.pop("jql", self.filter_jql), favourite=kwargs.pop("favourite", self.filter_favourite), ) if len(kwargs): raise ValueError("Incorrect kwarg used !") yield new_filter finally: new_filter.delete() def test_filter(self): with self.make_filter() as myfilter: self.assertEqual(myfilter.name, self.filter_name) self.assertEqual(myfilter.owner.name, self.test_manager.user_admin.name) def test_favourite_filters(self): filter_name = f"filter-to-fav-{self.filter_name}" with self.make_filter(name=filter_name, favourite=True): new_filters = self.jira.favourite_filters() assert filter_name in [f.name for f in new_filters] def test_filter_update_empty_description(self): new_jql = f"{self.filter_jql} ORDER BY created ASC" new_name = f"new_{self.filter_name}" with self.make_filter(description=None) as myfilter: self.jira.update_filter( myfilter.id, name=new_name, description=None, jql=new_jql, favourite=None, ) updated_filter = self.jira.filter(myfilter.id) assert updated_filter.name == new_name assert updated_filter.jql == new_jql assert not hasattr(updated_filter, "description") def test_filter_update_empty_description_with_new_description(self): new_desc = "new description" with self.make_filter(description=None) as myfilter: self.jira.update_filter( myfilter.id, name=myfilter.name, description=new_desc, jql=myfilter.jql, favourite=None, ) updated_filter = self.jira.filter(myfilter.id) assert updated_filter.description == new_desc jira-3.5.2/tests/resources/test_generic_resource.py000066400000000000000000000025311444726022700225670ustar00rootroot00000000000000from __future__ import annotations import pytest import jira.resources MOCK_URL = "http://customized-jira.com/rest/" def url_test_case(example_url: str): return f"{MOCK_URL}{example_url}" class TestResource: @pytest.mark.parametrize( ["example_url", "expected_class"], # fmt: off [ (url_test_case("api/latest/issue/JRA-1330"), jira.resources.Issue), (url_test_case("api/latest/project/BULK"), jira.resources.Project), (url_test_case("api/latest/project/IMG/role/10002"), jira.resources.Role), (url_test_case("plugin-resource/4.5/json/getMyObject"), jira.resources.UnknownResource), (url_test_case("group?groupname=bla"), jira.resources.Group), (url_test_case("user?username=bla"), jira.resources.User), # Jira Server / Data Center (url_test_case("user?accountId=bla"), jira.resources.User), # Jira Cloud ], # fmt: on ids=[ "issue", "project", "role", "unknown_resource", "group", "user", "user_cloud", ], ) def test_cls_for_resource(self, example_url, expected_class): """Test the regex recognizes the right class for a given URL.""" assert jira.resources.cls_for_resource(example_url) == expected_class jira-3.5.2/tests/resources/test_group.py000066400000000000000000000012531444726022700204000ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase, allow_on_cloud @allow_on_cloud class GroupsTest(JiraTestCase): def setUp(self) -> None: super().setUp() self.group_name = ( "administrators" if self.is_jira_cloud_ci else "jira-administrators" ) def test_group(self): group = self.jira.group(self.group_name) self.assertEqual(group.name, self.group_name) def test_groups(self): groups = self.jira.groups() self.assertGreater(len(groups), 0) def test_groups_for_users(self): groups = self.jira.groups(self.group_name) self.assertGreater(len(groups), 0) jira-3.5.2/tests/resources/test_issue.py000066400000000000000000000551411444726022700204010ustar00rootroot00000000000000from __future__ import annotations import logging from jira.exceptions import JIRAError from tests.conftest import JiraTestCase, find_by_key, find_by_key_value LOGGER = logging.getLogger(__name__) class IssueTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) 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, f"issue 1 from {self.project_b}") def test_issue_search_finds_issue(self): issues = self.jira.search_issues("key=%s" % self.issue_1) self.assertEqual(self.issue_1, issues[0].key) def test_issue_search_return_type(self): issues = self.jira.search_issues("key=%s" % self.issue_1) self.assertIsInstance(issues, list) issues = self.jira.search_issues("key=%s" % self.issue_1, json_result=True) self.assertIsInstance(issues, dict) def test_issue_search_only_includes_provided_fields(self): issues = self.jira.search_issues( "key=%s" % self.issue_1, fields="comment,assignee" ) self.assertTrue(hasattr(issues[0].fields, "comment")) self.assertTrue(hasattr(issues[0].fields, "assignee")) self.assertFalse(hasattr(issues[0].fields, "reporter")) def test_issue_search_default_behaviour_included_fields(self): search_str = f"key={self.issue_1}" issues = self.jira.search_issues(search_str) self.assertTrue(hasattr(issues[0].fields, "reporter")) self.assertTrue(hasattr(issues[0].fields, "comment")) # fields=None should be valid and return all fields (ie. default behavior) self.assertEqual( self.jira.search_issues(search_str), self.jira.search_issues(search_str, fields=None), ) def test_issue_get_field(self): issue = self.jira.issue(self.issue_1) self.assertEqual( issue.fields.description, issue.get_field(field_name="description") ) with self.assertRaisesRegex(AttributeError, ": _something"): issue.get_field("_something") with self.assertRaisesRegex(AttributeError, "customfield_1234"): issue.get_field("customfield_1234") def test_issue_field_limiting(self): issue = self.jira.issue(self.issue_2, fields="summary,comment") self.assertEqual(issue.fields.summary, f"issue 2 from {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") LOGGER.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(f"key={self.issue_1}") self.assertTrue(issue1 is not None) self.assertTrue(issue1 == issues[0]) self.assertFalse(issue2 == issues[0]) def test_issue_expand(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')) def test_create_issue_with_fieldargs(self): issue = self.jira.create_issue( summary="Test issue created", project=self.project_b, issuetype={"name": "Bug"}, description="foo description", ) # customfield_10022='XSS' self.assertEqual(issue.fields.summary, "Test issue created") self.assertEqual(issue.fields.description, "foo description") 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() def test_create_issue_with_fielddict(self): fields = { "summary": "Issue created from field dict", "project": {"key": self.project_b}, "issuetype": {"name": "Bug"}, "description": "Some new issue for test", # 'customfield_10022': 'XSS', "priority": {"name": "High"}, } 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, "High") issue.delete() def test_create_issue_without_prefetch(self): issue = self.jira.create_issue( summary="Test issue created", project=self.project_b, issuetype={"name": "Bug"}, description="some details", prefetch=False, ) # customfield_10022='XSS' assert hasattr(issue, "self") assert hasattr(issue, "raw") assert "fields" not in issue.raw issue.delete() def test_create_issues(self): field_list = [ { "summary": "Issue created via bulk create #1", "project": {"key": self.project_b}, "issuetype": {"name": "Bug"}, "description": "Some new issue for test", # 'customfield_10022': 'XSS', "priority": {"name": "High"}, }, { "summary": "Issue created via bulk create #2", "project": {"key": self.project_a}, "issuetype": {"name": "Bug"}, "description": "Another new issue for bulk test", "priority": {"name": "High"}, }, ] issues = self.jira.create_issues(field_list=field_list) self.assertEqual(len(issues), 2) self.assertIsNotNone(issues[0]["issue"], "the first issue has not been created") 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, "High") self.assertIsNotNone( issues[1]["issue"], "the second issue has not been created" ) 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, "High") for issue in issues: issue["issue"].delete() def test_create_issues_one_failure(self): field_list = [ { "summary": "Issue created via bulk create #1", "project": {"key": self.project_b}, "issuetype": {"name": "Bug"}, "description": "Some new issue for test", # 'customfield_10022': 'XSS', "priority": {"name": "High"}, }, { "summary": "This issue will not succeed", "project": {"key": self.project_a}, "issuetype": {"name": "InvalidIssueType"}, "description": "Should not be seen.", "priority": {"name": "High"}, }, { "summary": "However, this one will.", "project": {"key": self.project_a}, "issuetype": {"name": "Bug"}, "description": "Should be seen.", "priority": {"name": "High"}, }, ] 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, "High") 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, "High") self.assertEqual(issues[2]["error"], None) self.assertEqual(len(issues), 3) for issue in issues: if issue["issue"] is not None: issue["issue"].delete() def test_create_issues_without_prefetch(self): field_list = [ dict( summary="Test issue #1 created with dicts without prefetch", project=self.project_b, issuetype={"name": "Bug"}, description="some details", ), dict( summary="Test issue #2 created with dicts without prefetch", project=self.project_a, issuetype={"name": "Bug"}, description="foo description", ), ] 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() def test_create_issue_with_integer_issuetype(self): # take first existing issuetype to avoid problems due to hardcoded name/id later issue_types_resolved = self.jira.issue_types() dyn_it = issue_types_resolved[0] issue = self.jira.create_issue( summary="Test issue created using an integer issuetype", project=self.project_b, issuetype=int(dyn_it.id), ) self.assertEqual(issue.get_field("issuetype").name, dyn_it.name) def test_create_issue_with_issue_type_name(self): issue_types_resolved = self.jira.issue_types() dyn_it = issue_types_resolved[0] issue = self.jira.create_issue( summary="Test issue created using a str issuetype", project=self.project_b, issuetype=dyn_it.name, ) self.assertEqual(issue.get_field("issuetype").name, dyn_it.name) def test_update_with_fieldargs(self): issue = self.jira.create_issue( summary="Test issue for updating with fieldargs", project=self.project_b, issuetype={"name": "Bug"}, description="Will be updated shortly", ) # customfield_10022='XSS') issue.update( summary="Updated summary", description="Now updated", issuetype={"name": "Task"}, ) self.assertEqual(issue.fields.summary, "Updated summary") self.assertEqual(issue.fields.description, "Now updated") self.assertEqual(issue.fields.issuetype.name, "Task") # self.assertEqual(issue.fields.customfield_10022, 'XSS') self.assertEqual(issue.fields.project.key, self.project_b) issue.delete() def test_update_with_fielddict(self): issue = self.jira.create_issue( summary="Test issue for updating with fielddict", project=self.project_b, description="Will be updated shortly", issuetype={"name": "Bug"}, ) fields = { "summary": "Issue is updated", "description": "it sure is", "issuetype": {"name": "Task"}, # 'customfield_10022': 'DOC', "priority": {"name": "High"}, } 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, "Task") # self.assertEqual(issue.fields.customfield_10022, 'DOC') self.assertEqual(issue.fields.priority.name, "High") issue.delete() def test_update_with_label(self): issue = self.jira.create_issue( summary="Test issue for updating labels", project=self.project_b, 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( summary="Test issue for updating bad labels", project=self.project_b, 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) def test_update_with_notify_false(self): issue = self.jira.create_issue( summary="Test issue for updating wiith notify false", project=self.project_b, 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( summary="Test issue created", project=self.project_b, 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) def test_createmeta(self): meta = self.jira.createmeta() proj = find_by_key(meta["projects"], self.project_b) # we assume that this project should allow at least one issue type self.assertGreaterEqual(len(proj["issuetypes"]), 1) 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) def test_createmeta_filter_by_projectkeys_and_name(self): meta = self.jira.createmeta( projectKeys=(self.project_a, self.project_b), issuetypeNames="Task" ) self.assertEqual(len(meta["projects"]), 2) for project in meta["projects"]: self.assertEqual(len(project["issuetypes"]), 1) 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) issue_type_ids = dict() full_meta = self.jira.createmeta(projectIds=(proja.id, projb.id)) for project in full_meta["projects"]: for issue_t in project["issuetypes"]: issue_t_id = issue_t["id"] val = issue_type_ids.get(issue_t_id) if val is None: issue_type_ids[issue_t_id] = [] issue_type_ids[issue_t_id].append([project["id"]]) common_issue_ids = [] for key, val in issue_type_ids.items(): if len(val) == 2: common_issue_ids.append(key) self.assertNotEqual(len(common_issue_ids), 0) for_lookup_common_issue_ids = common_issue_ids if len(common_issue_ids) > 2: for_lookup_common_issue_ids = common_issue_ids[:-1] meta = self.jira.createmeta( projectIds=(proja.id, projb.id), issuetypeIds=for_lookup_common_issue_ids ) self.assertEqual(len(meta["projects"]), 2) for project in meta["projects"]: self.assertEqual( len(project["issuetypes"]), len(for_lookup_common_issue_ids) ) def test_createmeta_expand(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.user_normal.name)) self.assertEqual( self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name ) def test_assign_issue_with_issue_obj(self): issue = self.jira.issue(self.issue_1) x = self.jira.assign_issue(issue, self.user_normal.name) self.assertTrue(x) self.assertEqual( self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name ) def test_assign_to_bad_issue_raises(self): self.assertRaises(JIRAError, self.jira.assign_issue, "NOPE-1", "notauser") def test_unassign_issue(self): # Given: A user is assigned to an issue self.assertTrue(self.jira.assign_issue(self.issue_1, self.user_normal.name)) self.assertEqual( self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name ) # When: we unassign the issue self.assertTrue(self.jira.assign_issue(self.issue_1, None)) # Then: the issue has an assignee of None self.assertEqual(self.jira.issue(self.issue_1).fields.assignee, None) def test_assign_issue_automatic(self): # Given: A user is assigned to an issue self.assertTrue(self.jira.assign_issue(self.issue_1, self.user_normal.name)) self.assertEqual( self.jira.issue(self.issue_1).fields.assignee.name, self.user_normal.name ) # When: we assign the issue to "-1" self.assertTrue(self.jira.assign_issue(self.issue_1, "-1")) # Then: the issue has the default assignee (the admin user) self.assertEqual(self.jira.issue(self.issue_1).fields.assignee, self.user_admin) def test_editmeta(self): expected_fields = { "assignee", "attachment", "comment", "components", "description", "fixVersions", "issuelinks", "labels", "summary", } for i in (self.issue_1, self.issue_2): meta = self.jira.editmeta(i) meta_field_set = set(meta["fields"].keys()) self.assertEqual( meta_field_set.intersection(expected_fields), expected_fields ) 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_rank(self): def get_issues_ordered_by_rank(): """Search for the issues, returned in the order determined by their rank.""" return self.jira.search_issues( f"key in ({self.issue_1},{self.issue_2}) ORDER BY Rank ASC" ) self.jira.rank(self.issue_1, next_issue=self.issue_2) issues = get_issues_ordered_by_rank() assert (issues[0].key, issues[1].key) == (self.issue_1, self.issue_2) self.jira.rank(self.issue_2, next_issue=self.issue_1) issues = get_issues_ordered_by_rank() assert (issues[0].key, issues[1].key) == (self.issue_2, self.issue_1) self.jira.rank(self.issue_2, prev_issue=self.issue_1) issues = get_issues_ordered_by_rank() assert (issues[0].key, issues[1].key) == (self.issue_1, self.issue_2) jira-3.5.2/tests/resources/test_issue_link.py000066400000000000000000000035271444726022700214170ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class IssueLinkTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.link_types = self.test_manager.jira_admin.issue_link_types() def test_issue_link(self): self.link = self.test_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.test_manager.jira_admin.create_issue_link( self.link_types[0].outward, self.test_manager.project_b_issue1, self.test_manager.project_b_issue2, ) def test_create_issue_link_with_issue_link_obj(self): self.test_manager.jira_admin.create_issue_link( self.link_types[0], self.test_manager.project_b_issue1, self.test_manager.project_b_issue2, ) def test_create_issue_link_with_issue_obj(self): inwardissue = self.test_manager.jira_admin.issue( self.test_manager.project_b_issue1 ) self.assertIsNotNone(inwardissue) outwardissue = self.test_manager.jira_admin.issue( self.test_manager.project_b_issue2 ) self.assertIsNotNone(outwardissue) self.test_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.test_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) jira-3.5.2/tests/resources/test_issue_link_type.py000066400000000000000000000000001444726022700224370ustar00rootroot00000000000000jira-3.5.2/tests/resources/test_issue_property.py000066400000000000000000000014571444726022700223460ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class IssuePropertyTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_1 = self.test_manager.project_b_issue1 def test_issue_property(self): self.jira.add_issue_property( self.issue_1, "custom-property", "Testing a property value" ) properties = self.jira.issue_properties(self.issue_1) self.assertEqual(len(properties), 1) prop = self.jira.issue_property(self.issue_1, "custom-property") self.assertEqual(prop.key, "custom-property") self.assertEqual(prop.value, "Testing a property value") prop.delete() properties = self.jira.issue_properties(self.issue_1) self.assertEqual(len(properties), 0) jira-3.5.2/tests/resources/test_issue_type_scheme_associations.py000066400000000000000000000016261444726022700255440ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class IssueTypeSchemeAssociationTests(JiraTestCase): def test_scheme_associations(self): all_schemes = self.jira.issue_type_schemes() # there should be more than 1 scheme self.assertGreaterEqual(len(all_schemes), 2) test_pass = False for scheme in all_schemes: associations = self.jira.get_issue_type_scheme_associations(scheme["id"]) # As long as one of these schemes is associated with a project-like object # we're probably ok. if len(associations) > 0: self.assertTrue(associations[0].get("id", False)) self.assertTrue(associations[0].get("key", False)) self.assertTrue(associations[0].get("lead", False)) test_pass = True break self.assertTrue(test_pass) jira-3.5.2/tests/resources/test_priority.py000066400000000000000000000006171444726022700211300ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class PrioritiesTests(JiraTestCase): def test_priorities(self): priorities = self.jira.priorities() self.assertEqual(len(priorities), 5) def test_priority(self): priority = self.jira.priority("2") self.assertEqual(priority.id, "2") self.assertEqual(priority.name, "High") jira-3.5.2/tests/resources/test_project.py000066400000000000000000000236131444726022700207160ustar00rootroot00000000000000from __future__ import annotations from jira import JIRAError from tests.conftest import JiraTestCase, find_by_id, rndstr class ProjectTests(JiraTestCase): 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) def test_project_expand(self): project = self.jira.project(self.project_b) self.assertFalse(hasattr(project, "projectKeys")) project = self.jira.project(self.project_b, expand="projectKeys") self.assertTrue(hasattr(project, "projectKeys")) def test_projects_expand(self): projects = self.jira.projects() for project in projects: self.assertFalse(hasattr(project, "projectKeys")) projects = self.jira.projects(expand="projectKeys") for project in projects: self.assertTrue(hasattr(project, "projectKeys")) # 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 = f"component-{proj} from project {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 = f"version-{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(self.test_manager.project_b_issue1) i.update(fields={"fixVersions": [{"id": version.id}]}) version.delete() def test_update_project_version(self): # given name = f"version-{rndstr()}" version = self.jira.create_version(name, self.project_b, "will be deleted soon") updated_name = f"version-{rndstr()}" # when version.update(name=updated_name) # then self.assertEqual(updated_name, version.name) version.delete() def test_get_project_version_by_name(self): name = f"version-{rndstr()}" version = self.jira.create_version(name, self.project_b, "will be deleted soon") found_version = self.jira.get_project_version_by_name(self.project_b, name) self.assertEqual(found_version.id, version.id) self.assertEqual(found_version.name, name) not_found_version = self.jira.get_project_version_by_name( self.project_b, "non-existent" ) self.assertEqual(not_found_version, None) i = self.jira.issue(self.test_manager.project_b_issue1) i.update(fields={"fixVersions": [{"id": version.id}]}) version.delete() def test_rename_version(self): old_name = f"version-{rndstr()}" version = self.jira.create_version( old_name, self.project_b, "will be deleted soon" ) new_name = old_name + "-renamed" self.jira.rename_version(self.project_b, old_name, new_name) found_version = self.jira.get_project_version_by_name(self.project_b, new_name) self.assertEqual(found_version.id, version.id) self.assertEqual(found_version.name, new_name) not_found_version = self.jira.get_project_version_by_name( self.project_b, old_name ) self.assertEqual(not_found_version, None) i = self.jira.issue(self.test_manager.project_b_issue1) i.update(fields={"fixVersions": [{"id": version.id}]}) version.delete() def test_project_versions_with_project_obj(self): name = f"version-{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() def test_project_roles(self): role_name = "Administrators" admin = None roles = self.jira.project_roles(self.project_b) self.assertGreaterEqual(len(roles), 1) self.assertIn(role_name, roles) admin = roles[role_name] self.assertTrue(admin) role = self.jira.project_role(self.project_b, admin["id"]) self.assertEqual(role.id, int(admin["id"])) actornames = {actor.name: actor for actor in role.actors} actor_admin = "jira-administrators" self.assertIn(actor_admin, actornames) members = self.jira.group_members(actor_admin) user = self.user_admin self.assertIn(user.name, members.keys()) role.update(users=user.name, groups=actor_admin) role = self.jira.project_role(self.project_b, int(admin["id"])) self.assertIn(user.name, [a.name for a in role.actors]) self.assertIn(actor_admin, [a.name for a in role.actors]) def test_project_permission_scheme(self): permissionscheme = self.jira.project_permissionscheme(self.project_b) self.assertEqual(permissionscheme.name, "Default Permission Scheme") def test_project_priority_scheme(self): priorityscheme = self.jira.project_priority_scheme(self.project_b) self.assertEqual(priorityscheme.name, "Default priority scheme") def test_project_notification_scheme(self): notificationscheme = self.jira.project_notification_scheme(self.project_b) self.assertEqual(notificationscheme.name, "Default Notification Scheme") def test_project_issue_security_level_scheme(self): # 404s are thrown when a project does not have an issue security scheme # associated with it explicitly. There are no ReST APIs for creating an # issue security scheme programmatically, so there is no way to test # this on the fly. with self.assertRaises(JIRAError): self.jira.project_issue_security_level_scheme(self.project_b) def test_project_workflow_scheme(self): workflowscheme = self.jira.project_workflow_scheme(self.project_b) self.assertEqual( workflowscheme.name, f"{self.project_b}: Software Simplified Workflow Scheme", ) jira-3.5.2/tests/resources/test_project_statuses.py000066400000000000000000000020711444726022700226440ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class ProjectStatusesByIssueTypeTests(JiraTestCase): def test_issue_types_for_project(self): issue_types = self.jira.issue_types_for_project(self.project_a) # should have at least one issue type within the project self.assertGreater(len(issue_types), 0) # get unique statuses across all issue types statuses = [] for issue_type in issue_types: # should have at least one valid status within an issue type by endpoint documentation self.assertGreater(len(issue_type.statuses), 0) statuses.extend(issue_type.statuses) unique_statuses = list(set(statuses)) # test status id and name for each status within the project for status in unique_statuses: self_status_id = self.jira.status(status.id).id self.assertEqual(self_status_id, status.id) self_status_name = self.jira.status(status.name).name self.assertEqual(self_status_name, status.name) jira-3.5.2/tests/resources/test_remote_link.py000066400000000000000000000142771444726022700215660ustar00rootroot00000000000000from __future__ import annotations from jira.exceptions import JIRAError from tests.conftest import JiraTestCase DEFAULT_NEW_REMOTE_LINK_OBJECT = {"url": "http://google.com", "title": "googlicious!"} class RemoteLinkTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) 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 self.project_b_issue1_obj = self.test_manager.project_b_issue1_obj def test_remote_links(self): self.jira.add_remote_link( self.issue_1, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, ) links = self.jira.remote_links(self.issue_1) self.assertEqual(len(links), 1) self.jira.remote_link(self.issue_1, links[0].id).delete() links = self.jira.remote_links(self.issue_2) self.assertEqual(len(links), 0) def test_remote_links_with_issue_obj(self): self.jira.add_remote_link( self.issue_1, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, ) links = self.jira.remote_links(self.project_b_issue1_obj) self.assertEqual(len(links), 1) self.jira.remote_link(self.issue_1, links[0].id).delete() links = self.jira.remote_links(self.project_b_issue1_obj) self.assertEqual(len(links), 0) def test_remote_link(self): added_link = self.jira.add_remote_link( self.issue_1, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", application={"name": "far too silly", "type": "sketch"}, relationship="mousebending", ) link = self.jira.remote_link(self.issue_1, added_link.id) self.assertEqual(link.id, added_link.id) self.assertTrue(hasattr(link, "globalId")) self.assertTrue(hasattr(link, "relationship")) self.assertTrue(hasattr(link, "application")) self.assertTrue(hasattr(link, "object")) link.delete() def test_remote_link_with_issue_obj(self): added_link = self.jira.add_remote_link( self.issue_1, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", application={"name": "far too silly", "type": "sketch"}, relationship="mousebending", ) link = self.jira.remote_link(self.project_b_issue1_obj, added_link.id) self.assertEqual(link.id, added_link.id) self.assertTrue(hasattr(link, "globalId")) self.assertTrue(hasattr(link, "relationship")) self.assertTrue(hasattr(link, "application")) self.assertTrue(hasattr(link, "object")) link.delete() def test_add_remote_link(self): link = self.jira.add_remote_link( self.issue_1, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", 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(self.issue_1, link.id) self.assertEqual(link.application.name, "far too silly") self.assertEqual(link.application.type, "sketch") self.assertEqual(link.object.url, DEFAULT_NEW_REMOTE_LINK_OBJECT["url"]) self.assertEqual(link.object.title, DEFAULT_NEW_REMOTE_LINK_OBJECT["title"]) self.assertEqual(link.relationship, "mousebending") self.assertEqual(link.globalId, "python-test:story.of.horse.riding") link.delete() def test_add_remote_link_with_issue_obj(self): link = self.jira.add_remote_link( self.project_b_issue1_obj, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", 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(self.project_b_issue1_obj, link.id) self.assertEqual(link.application.name, "far too silly") self.assertEqual(link.application.type, "sketch") self.assertEqual(link.object.url, DEFAULT_NEW_REMOTE_LINK_OBJECT["url"]) self.assertEqual(link.object.title, DEFAULT_NEW_REMOTE_LINK_OBJECT["title"]) self.assertEqual(link.relationship, "mousebending") self.assertEqual(link.globalId, "python-test:story.of.horse.riding") link.delete() def test_update_remote_link(self): link = self.jira.add_remote_link( self.issue_1, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", 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(self.issue_1, link.id) new_link = {"url": "http://yahoo.com", "title": "yahoo stuff"} link.update( object=new_link, 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, new_link["url"]) self.assertEqual(link.object.title, new_link["title"]) link.delete() def test_delete_remote_link(self): link = self.jira.add_remote_link( self.issue_1, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.horse.riding", application={"name": "far too silly", "type": "sketch"}, relationship="mousebending", ) _id = link.id link = self.jira.remote_link(self.issue_1, link.id) link.delete() self.assertRaises(JIRAError, self.jira.remote_link, self.issue_1, _id) jira-3.5.2/tests/resources/test_request_type.py000066400000000000000000000000001444726022700217620ustar00rootroot00000000000000jira-3.5.2/tests/resources/test_resolution.py000066400000000000000000000006611444726022700214510ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class ResolutionTests(JiraTestCase): def test_resolutions(self): resolutions = self.jira.resolutions() self.assertGreaterEqual(len(resolutions), 1) def test_resolution(self): resolution = self.jira.resolution("10002") self.assertEqual(resolution.id, "10002") self.assertEqual(resolution.name, "Duplicate") jira-3.5.2/tests/resources/test_role.py000066400000000000000000000000001444726022700201720ustar00rootroot00000000000000jira-3.5.2/tests/resources/test_security_level.py000066400000000000000000000007351444726022700223060ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase, broken_test @broken_test( reason="Skipped due to standalone jira docker image has no security schema created by default" ) class SecurityLevelTests(JiraTestCase): 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") jira-3.5.2/tests/resources/test_service_desk.py000066400000000000000000000040051444726022700217100ustar00rootroot00000000000000from __future__ import annotations import logging from time import sleep import pytest from tests.conftest import JiraTestCase, broken_test LOGGER = logging.getLogger(__name__) class JiraServiceDeskTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) if not self.jira.supports_service_desk(): pytest.skip("Skipping Service Desk not enabled") try: self.jira.delete_project(self.test_manager.project_sd) except Exception: LOGGER.warning("Failed to delete %s", self.test_manager.project_sd) @broken_test(reason="Broken needs fixing") def test_create_customer_request(self): self.jira.create_project( key=self.test_manager.project_sd, name=self.test_manager.project_sd_name, ptype="service_desk", template_name="IT Service Desk", ) service_desks = [] for _ in range(3): service_desks = self.jira.service_desks() if service_desks: break logging.warning("Service desk not reported...") sleep(2) self.assertTrue(service_desks, "No service desks were found!") service_desk = service_desks[0] for _ in range(3): request_types = self.jira.request_types(service_desk) if request_types: logging.warning("Service desk request_types not reported...") break sleep(2) self.assertTrue(request_types, "No request_types for service desk found!") request = self.jira.create_customer_request( dict( serviceDeskId=service_desk.id, requestTypeId=int(request_types[0].id), requestFieldValues=dict( summary="Ticket title here", description="Ticket body here" ), ) ) self.assertEqual(request.fields.summary, "Ticket title here") self.assertEqual(request.fields.description, "Ticket body here") jira-3.5.2/tests/resources/test_sprint.py000066400000000000000000000102021444726022700205550ustar00rootroot00000000000000from __future__ import annotations from contextlib import contextmanager from functools import lru_cache from typing import Iterator import pytest as pytest from jira.exceptions import JIRAError from jira.resources import Board, Filter, Sprint from tests.conftest import JiraTestCase, rndstr class SprintTests(JiraTestCase): def setUp(self): super().setUp() 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 uniq = rndstr() self.board_name = f"board-{uniq}" self.sprint_name = f"sprint-{uniq}" self.filter_name = f"filter-{uniq}" self.board, self.filter = self._create_board_and_filter() def tearDown(self) -> None: self.board.delete() self.filter.delete() # must do AFTER deleting board referencing the filter super().tearDown() def _create_board_and_filter(self) -> tuple[Board, Filter]: """Helper method to create a board and filter""" filter = self.jira.create_filter( self.filter_name, "description", f"project={self.project_b}", True ) board = self.jira.create_board( name=self.board_name, filter_id=filter.id, project_ids=self.project_b ) return board, filter @contextmanager def _create_sprint(self) -> Iterator[Sprint]: """Helper method to create a Sprint.""" sprint = None try: sprint = self.jira.create_sprint(self.sprint_name, self.board.id) yield sprint finally: if sprint is not None: sprint.delete() @lru_cache def _sprint_customfield(self) -> str: """Helper method to return the customfield_ name for a sprint. This is needed as it is implemented as a plugin to Jira, (Jira Agile). """ sprint_field_name = "Sprint" sprint_field_id = [ f["schema"]["customId"] for f in self.jira.fields() if f["name"] == sprint_field_name ][0] return f"customfield_{sprint_field_id}" def test_create_and_delete(self): # GIVEN: the board and filter # WHEN: we create the sprint with self._create_sprint() as sprint: sprint = self.jira.create_sprint(self.sprint_name, self.board.id) # THEN: we get a sprint with some reasonable defaults assert isinstance(sprint.id, int) assert sprint.name == self.sprint_name assert sprint.state.upper() == "FUTURE" # THEN: the sprint .delete() is called successfully def test_add_issue_to_sprint(self): # GIVEN: The sprint with self._create_sprint() as sprint: # WHEN: we add an issue to the sprint self.jira.add_issues_to_sprint(sprint.id, [self.issue_1]) updated_issue_1 = self.jira.issue(self.issue_1) serialised_sprint = updated_issue_1.get_field(self._sprint_customfield())[0] # THEN: We find this sprint in the Sprint field of the Issue assert f"[id={sprint.id}," in serialised_sprint def test_move_issue_to_backlog(self): with self._create_sprint() as sprint: # GIVEN: we have an issue in a sprint self.jira.add_issues_to_sprint(sprint.id, [self.issue_1]) updated_issue_1 = self.jira.issue(self.issue_1) assert updated_issue_1.get_field(self._sprint_customfield()) is not None # WHEN: We move it to the backlog self.jira.move_to_backlog([updated_issue_1.key]) updated_issue_1 = self.jira.issue(updated_issue_1) # THEN: There is no longer the sprint assigned updated_issue_1 = self.jira.issue(self.issue_1) assert updated_issue_1.get_field(self._sprint_customfield()) is None def test_two_sprints_with_the_same_name_raise_a_jira_error_when_sprints_by_name_is_called( self, ): with self._create_sprint(): with self._create_sprint(): with pytest.raises(JIRAError): self.jira.sprints_by_name(self.board.id) jira-3.5.2/tests/resources/test_status.py000066400000000000000000000010671444726022700205720ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class StatusTests(JiraTestCase): def test_statuses(self): found = False statuses = self.jira.statuses() for status in statuses: if status.name == "Done": found = True # find status s = self.jira.status(status.id) self.assertEqual(s.id, status.id) break self.assertTrue(found, f"Status Done not found. [{statuses}]") self.assertGreater(len(statuses), 0) jira-3.5.2/tests/resources/test_status_category.py000066400000000000000000000014111444726022700224600ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class StatusCategoryTests(JiraTestCase): def test_statuscategories(self): found = False statuscategories = self.jira.statuscategories() for statuscategory in statuscategories: if statuscategory.id == 1 and statuscategory.name == "No Category": found = True break self.assertTrue( found, f"StatusCategory with id=1 not found. [{statuscategories}]" ) self.assertGreater(len(statuscategories), 0) def test_statuscategory(self): statuscategory = self.jira.statuscategory(1) self.assertEqual(statuscategory.id, 1) self.assertEqual(statuscategory.name, "No Category") jira-3.5.2/tests/resources/test_user.py000066400000000000000000000204411444726022700202220ustar00rootroot00000000000000from __future__ import annotations import os from jira.resources import User from tests.conftest import TEST_ICON_PATH, JiraTestCase, allow_on_cloud class UserTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue = self.test_manager.project_b_issue3 @allow_on_cloud def test_user(self): """Test that a user can be returned and is the right class""" # GIVEN: a User expected_user = self.test_manager.user_admin # WHEN: The user is searched for using its identifying attribute user = self.jira.user(getattr(expected_user, self.identifying_user_property)) # THEN: it is of the right type, and has an email address of the right format assert isinstance(user, User) self.assertRegex( user.emailAddress, r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$" ) def test_search_assignable_users_for_projects(self): users = self.jira.search_assignable_users_for_projects( self.test_manager.CI_JIRA_ADMIN, f"{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) def test_search_assignable_users_for_projects_maxresults(self): users = self.jira.search_assignable_users_for_projects( self.test_manager.CI_JIRA_ADMIN, f"{self.project_a},{self.project_b}", maxResults=1, ) self.assertLessEqual(len(users), 1) def test_search_assignable_users_for_projects_startat(self): users = self.jira.search_assignable_users_for_projects( self.test_manager.CI_JIRA_ADMIN, f"{self.project_a},{self.project_b}", startAt=1, ) self.assertGreaterEqual(len(users), 0) 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) 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) 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) 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) 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) 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) 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( self.test_manager.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( self.test_manager.CI_JIRA_ADMIN, props ) self.assertIn("id", avatar_props) self.assertEqual(avatar_props["owner"], self.test_manager.CI_JIRA_ADMIN) self.jira.set_user_avatar(self.test_manager.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) 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]["id"] ) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) self.assertEqual( find_selected_avatar(avatars)["id"], avatars["system"][0]["id"] ) self.jira.set_user_avatar( self.test_manager.CI_JIRA_ADMIN, avatars["system"][1]["id"] ) avatars = self.jira.user_avatars(self.test_manager.CI_JIRA_ADMIN) self.assertEqual( find_selected_avatar(avatars)["id"], avatars["system"][1]["id"] ) def test_delete_user_avatar(self): size = os.path.getsize(TEST_ICON_PATH) with open(TEST_ICON_PATH, "rb") as icon: props = self.jira.create_temp_user_avatar( self.test_manager.CI_JIRA_ADMIN, TEST_ICON_PATH, size, icon.read(), auto_confirm=True, ) self.jira.delete_user_avatar(self.test_manager.CI_JIRA_ADMIN, props["id"]) @allow_on_cloud def test_search_users(self): # WHEN: the search_users function is called with a requested user if self.is_jira_cloud_ci: users = self.jira.search_users(query=self.test_manager.CI_JIRA_ADMIN) else: users = self.jira.search_users(self.test_manager.CI_JIRA_ADMIN) # THEN: We get a list of User objects self.assertGreaterEqual(len(users), 1) self.assertIsInstance(users[0], User) # and the requested user can be found in this list user_ids = [getattr(user, self.identifying_user_property) for user in users] self.assertIn( getattr(self.test_manager.user_admin, self.identifying_user_property), user_ids, ) def test_search_users_maxresults(self): users = self.jira.search_users(self.test_manager.CI_JIRA_USER, maxResults=1) self.assertGreaterEqual(1, len(users)) 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) @allow_on_cloud 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) self.assertIsInstance(users[0], User) 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) 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 = {self.test_manager.user_admin, self.test_manager.user_admin} self.assertEqual(len(users_set), 1) jira-3.5.2/tests/resources/test_version.py000066400000000000000000000041131444726022700207270ustar00rootroot00000000000000from __future__ import annotations from jira.exceptions import JIRAError from tests.conftest import JiraTestCase class VersionTests(JiraTestCase): 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() 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() 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.test_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) jira-3.5.2/tests/resources/test_vote.py000066400000000000000000000023471444726022700202260ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class VoteTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_1 = self.test_manager.project_b_issue1 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 jira-3.5.2/tests/resources/test_watchers.py000066400000000000000000000016641444726022700210720ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class WatchersTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_1 = self.test_manager.project_b_issue1 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.user_normal.name) init_watchers = self.jira.watchers(self.issue_1).watchCount # adding a new watcher self.jira.add_watcher(self.issue_1, self.test_manager.user_normal.name) 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.user_normal.name) new_watchers = self.jira.watchers(self.issue_1).watchCount self.assertEqual(init_watchers, new_watchers) jira-3.5.2/tests/resources/test_worklog.py000066400000000000000000000055211444726022700207320ustar00rootroot00000000000000from __future__ import annotations from tests.conftest import JiraTestCase class WorklogTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) 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_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.user_admin.name) 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.user_admin.name) 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") jira-3.5.2/tests/ruff.toml000066400000000000000000000002531444726022700154570ustar00rootroot00000000000000extend = "../pyproject.toml" ignore = [ "E501", # We have way too many "line too long" errors at the moment "D", # Too many undocumented functions at the moment ] jira-3.5.2/tests/start-jira.sh000077500000000000000000000006501444726022700162400ustar00rootroot00000000000000#!/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 responding on $JIRA_URL " until $(curl --output /dev/null --silent --head --fail $JIRA_URL); do printf '.' sleep 5 done jira-3.5.2/tests/stop-jira.sh000077500000000000000000000002561444726022700160720ustar00rootroot00000000000000#!/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-3.5.2/tests/test_client.py000066400000000000000000000173131444726022700165140ustar00rootroot00000000000000from __future__ import annotations import getpass from unittest import mock import pytest import requests.sessions import jira.client from jira.exceptions import JIRAError from tests.conftest import JiraTestManager, get_unique_project_name @pytest.fixture() def prep(): pass @pytest.fixture(scope="module") def test_manager() -> JiraTestManager: return JiraTestManager() @pytest.fixture() def cl_admin(test_manager: JiraTestManager) -> jira.client.JIRA: return test_manager.jira_admin @pytest.fixture() def cl_normal(test_manager: JiraTestManager) -> jira.client.JIRA: 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, JIRAError): # Some tests have project already removed, so we stay silent pass slug = get_unique_project_name() project_name = f"Test user={getpass.getuser()} key={slug} A" 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 def test_delete_project(cl_admin, cl_normal, slug): assert cl_admin.delete_project(slug) def test_delete_inexistent_project(cl_admin): slug = "abogus123" with pytest.raises(JIRAError) as ex: assert cl_admin.delete_project(slug) assert "No project could be found with key" in str( ex.value ) or f'Parameter pid="{slug}" is not a Project, projectID or slug' in str(ex.value) def test_templates(cl_admin): templates = set(cl_admin.templates()) expected_templates = set( filter( None, """ Basic software development Kanban software development Process management Project management Scrum software development Task management """.split( "\n" ), ) ) assert templates == expected_templates def test_result_list(): iterable = [2, 3] startAt = 0 maxResults = 50 total = 2 results = jira.client.ResultList(iterable, startAt, maxResults, total) for idx, result in enumerate(results): assert results[idx] == iterable[idx] assert next(results) == iterable[0] assert next(results) == iterable[1] with pytest.raises(StopIteration): next(results) def test_result_list_if_empty(): results = jira.client.ResultList() for r in results: raise AssertionError("`results` should be empty") with pytest.raises(StopIteration): next(results) @pytest.mark.parametrize( "options_arg", [ {"headers": {"Content-Type": "application/json;charset=UTF-8"}}, {"headers": {"random-header": "nice random"}}, ], ids=["overwrite", "new"], ) def test_headers_unclobbered_update(options_arg, no_fields): assert "headers" in options_arg, "test case options must contain headers" # GIVEN: the headers and the expected value header_to_check: str = list(options_arg["headers"].keys())[0] expected_header_value: str = options_arg["headers"][header_to_check] invariant_header_name: str = "X-Atlassian-Token" invariant_header_value: str = jira.client.JIRA.DEFAULT_OPTIONS["headers"][ invariant_header_name ] # We arbitrarily chose a header to check it remains unchanged/unclobbered # so should not be overwritten by a test case assert ( invariant_header_name not in options_arg["headers"] ), f"{invariant_header_name} is checked as not being overwritten in this test" # WHEN: we initialise the JIRA class and get the headers jira_client = jira.client.JIRA( server="https://jira.atlasian.com", get_server_info=False, validate=False, options=options_arg, ) session_headers = jira_client._session.headers # THEN: we have set the right headers and not affect the other headers' defaults assert session_headers[header_to_check] == expected_header_value assert session_headers[invariant_header_name] == invariant_header_value def test_headers_unclobbered_update_with_no_provided_headers(no_fields): options_arg = {} # a dict with "headers" not set # GIVEN:the headers and the expected value invariant_header_name: str = "X-Atlassian-Token" invariant_header_value: str = jira.client.JIRA.DEFAULT_OPTIONS["headers"][ invariant_header_name ] # WHEN: we initialise the JIRA class with no provided headers and get the headers jira_client = jira.client.JIRA( server="https://jira.atlasian.com", get_server_info=False, validate=False, options=options_arg, ) session_headers = jira_client._session.headers # THEN: we have not affected the other headers' defaults assert session_headers[invariant_header_name] == invariant_header_value def test_token_auth(cl_admin: jira.client.JIRA): """Tests the Personal Access Token authentication works.""" # GIVEN: We have a PAT token created by a user. pat_token_request = { "name": "my_new_token", "expirationDuration": 1, } base_url = cl_admin.server_url pat_token_response = cl_admin._session.post( f"{base_url}/rest/pat/latest/tokens", json=pat_token_request ).json() new_token = pat_token_response["rawToken"] # WHEN: A new client is authenticated with this token new_jira_client = jira.client.JIRA(token_auth=new_token) # THEN: The reported authenticated user of the token # matches the original token creator user. assert cl_admin.myself() == new_jira_client.myself() def test_bearer_token_auth(): my_token = "cool-token" token_auth_jira = jira.client.JIRA( server="https://what.ever", token_auth=my_token, get_server_info=False, validate=False, ) method_send = token_auth_jira._session.send with mock.patch.object(token_auth_jira._session, method_send.__name__) as mock_send: token_auth_jira._session.get(token_auth_jira.server_url) prepared_req: requests.sessions.PreparedRequest = mock_send.call_args[0][0] assert prepared_req.headers["Authorization"] == f"Bearer {my_token}" def test_cookie_auth(test_manager: JiraTestManager): """Test Cookie based authentication works. NOTE: this is deprecated in Cloud and is not recommended in Server. https://developer.atlassian.com/cloud/jira/platform/jira-rest-api-cookie-based-authentication/ https://developer.atlassian.com/server/jira/platform/cookie-based-authentication/ """ # GIVEN: the username and password # WHEN: We create a session with cookie auth for the same server cookie_auth_jira = jira.client.JIRA( server=test_manager.CI_JIRA_URL, auth=(test_manager.CI_JIRA_ADMIN, test_manager.CI_JIRA_ADMIN_PASSWORD), ) # THEN: We get the same result from the API assert test_manager.jira_admin.myself() == cookie_auth_jira.myself() def test_cookie_auth_retry(): """Test Cookie based authentication retry logic works.""" # GIVEN: arguments that will cause a 401 error auth_class = jira.client.JiraCookieAuth reset_func = jira.client.JiraCookieAuth._reset_401_retry_counter new_options = jira.client.JIRA.DEFAULT_OPTIONS.copy() new_options["auth_url"] = "/401" with pytest.raises(JIRAError): with mock.patch.object(auth_class, reset_func.__name__) as mock_reset_func: # WHEN: We create a session with cookie auth jira.client.JIRA( server="https://httpstat.us", options=new_options, auth=("user", "pass"), ) # THEN: We don't get a RecursionError and only call the reset_function once mock_reset_func.assert_called_once() jira-3.5.2/tests/test_exceptions.py000066400000000000000000000125551444726022700174220ustar00rootroot00000000000000from __future__ import annotations import unittest from pathlib import Path from unittest.mock import mock_open, patch from requests import Response from requests.structures import CaseInsensitiveDict from jira.exceptions import JIRAError DUMMY_HEADERS = {"h": "nice headers"} DUMMY_TEXT = "nice text" DUMMY_URL = "https://nice.jira.tests" DUMMY_STATUS_CODE = 200 PATCH_BASE = "jira.exceptions" class ExceptionsTests(unittest.TestCase): class MockResponse(Response): def __init__( self, headers: dict = None, text: str = "", status_code: int = DUMMY_STATUS_CODE, url: str = DUMMY_URL, ): """Sub optimal but we create a mock response like this.""" self.headers = CaseInsensitiveDict(headers if headers else {}) self._text = text self.status_code = status_code self.url = url @property def text(self): return self._text @text.setter def text(self, new_text): self._text = new_text class MalformedMockResponse: def __init__( self, headers: dict = None, text: str = "", status_code: int = DUMMY_STATUS_CODE, url: str = DUMMY_URL, ): if headers: self.headers = headers if text: self.text = text self.url = url self.status_code = status_code def test_jira_error_response_added(self): err = JIRAError( response=self.MockResponse(headers=DUMMY_HEADERS, text=DUMMY_TEXT) ) err_str = str(err) assert f"headers = {DUMMY_HEADERS}" in err_str assert f"text = {DUMMY_TEXT}" in err_str def test_jira_error_malformed_response(self): # GIVEN: a malformed Response object, without headers or text set bad_repsonse = self.MalformedMockResponse() # WHEN: The JiraError's __str__ method is called err = JIRAError(response=bad_repsonse) err_str = str(err) # THEN: there are no errors and neither headers nor text are in the result assert "headers = " not in err_str assert "text = " not in err_str def test_jira_error_request_added(self): err = JIRAError( request=self.MockResponse(headers=DUMMY_HEADERS, text=DUMMY_TEXT) ) err_str = str(err) assert f"headers = {DUMMY_HEADERS}" in err_str assert f"text = {DUMMY_TEXT}" in err_str def test_jira_error_malformed_request(self): # GIVEN: a malformed Response object, without headers or text set bad_repsonse = self.MalformedMockResponse() # WHEN: The JiraError's __str__ method is called err = JIRAError(request=bad_repsonse) err_str = str(err) # THEN: there are no errors and neither headers nor text are in the result assert "headers = " not in err_str assert "text = " not in err_str def test_jira_error_url_added(self): assert f"url: {DUMMY_URL}" in str(JIRAError(url=DUMMY_URL)) def test_jira_error_status_code_added(self): assert f"JiraError HTTP {DUMMY_STATUS_CODE}" in str( JIRAError(status_code=DUMMY_STATUS_CODE) ) def test_jira_error_text_added(self): dummy_text = "wow\tthis\nis\nso cool" assert f"text: {dummy_text}" in str(JIRAError(text=dummy_text)) def test_jira_error_log_to_tempfile_if_env_var_set(self): # GIVEN: the right env vars are set and the tempfile's filename env_vars = {"PYJIRA_LOG_TO_TEMPFILE": "so true"} test_jira_error_filename = ( Path(__file__).parent / "test_jira_error_log_to_tempfile.bak" ) # https://docs.python.org/3/library/unittest.mock.html#mock-open mocked_open = mock_open() # WHEN: a JIRAError's __str__ method is called and # log details are expected to be sent to the tempfile with patch.dict("os.environ", env_vars), patch( f"{PATCH_BASE}.tempfile.mkstemp", autospec=True ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): mock_mkstemp.return_value = 0, str(test_jira_error_filename) str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) # THEN: the known filename is opened and contains the exception details mocked_open.assert_called_once_with(str(test_jira_error_filename), "w") mock_file_stream = mocked_open() assert f"text = {DUMMY_TEXT}" in mock_file_stream.write.call_args[0][0] def test_jira_error_log_to_tempfile_not_used_if_env_var_not_set(self): # GIVEN: no env vars are set and the tempfile's filename env_vars = {} test_jira_error_filename = ( Path(__file__).parent / "test_jira_error_log_to_tempfile.bak" ) # https://docs.python.org/3/library/unittest.mock.html#mock-open mocked_open = mock_open() # WHEN: a JIRAError's __str__ method is called with patch.dict("os.environ", env_vars), patch( f"{PATCH_BASE}.tempfile.mkstemp", autospec=True ) as mock_mkstemp, patch(f"{PATCH_BASE}.open", mocked_open): mock_mkstemp.return_value = 0, str(test_jira_error_filename) str(JIRAError(response=self.MockResponse(text=DUMMY_TEXT))) # THEN: no files are opened mocked_open.assert_not_called() jira-3.5.2/tests/test_qsh.py000066400000000000000000000024371444726022700160320ustar00rootroot00000000000000from __future__ import annotations import pytest from jira.client import QshGenerator class MockRequest: def __init__(self, method, url): self.method = method self.url = url @pytest.mark.parametrize( "method,url,expected", [ ("GET", "http://example.com", "GET&&"), # empty parameter ("GET", "http://example.com?key=&key2=A", "GET&&key=&key2=A"), # whitespace ("GET", "http://example.com?key=A+B", "GET&&key=A%20B"), # tilde ("GET", "http://example.com?key=A~B", "GET&&key=A~B"), # repeated parameters ( "GET", "http://example.com?key2=Z&key1=X&key3=Y&key1=A", "GET&&key1=A,X&key2=Z&key3=Y", ), # repeated parameters with whitespace ( "GET", "http://example.com?key2=Z+A&key1=X+B&key3=Y&key1=A+B", "GET&&key1=A%20B,X%20B&key2=Z%20A&key3=Y", ), ], ids=[ "no parameters", "empty parameter", "whitespace", "tilde", "repeated parameters", "repeated parameters with whitespace", ], ) def test_qsh(method, url, expected): gen = QshGenerator("http://example.com") req = MockRequest(method, url) assert gen._generate_qsh(req) == expected jira-3.5.2/tests/test_resilientsession.py000066400000000000000000000176661444726022700206530ustar00rootroot00000000000000from __future__ import annotations import logging from unittest.mock import Mock, patch import pytest from requests import Response import jira.resilientsession from jira.exceptions import JIRAError from jira.resilientsession import parse_error_msg, parse_errors from tests.conftest import JiraTestCase class ListLoggingHandler(logging.Handler): """A logging handler that records all events in a list.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.records = [] def emit(self, record): self.records.append(record) def reset(self): self.records = [] class ResilientSessionLoggingConfidentialityTests(JiraTestCase): """No sensitive data shall be written to the log.""" def setUp(self): self.loggingHandler = ListLoggingHandler() jira.resilientsession.logging.getLogger().addHandler(self.loggingHandler) def test_logging_with_connection_error(self): """No sensitive data shall be written to the log in case of a connection error.""" witness = "etwhpxbhfniqnbbjoqvw" # random string; hopefully unique for max_retries in (0, 1): for verb in ("get", "post", "put", "delete", "head", "patch", "options"): with self.subTest(max_retries=max_retries, verb=verb): with jira.resilientsession.ResilientSession() as session: session.max_retries = max_retries session.max_retry_delay = 0 try: getattr(session, verb)( "http://127.0.0.1:9", headers={"sensitive_header": witness}, data={"sensitive_data": witness}, ) except jira.resilientsession.ConnectionError: pass # check that `witness` does not appear in log for record in self.loggingHandler.records: self.assertNotIn(witness, record.msg) for arg in record.args: self.assertNotIn(witness, str(arg)) self.assertNotIn(witness, str(record)) self.loggingHandler.reset() def tearDown(self): jira.resilientsession.logging.getLogger().removeHandler(self.loggingHandler) del self.loggingHandler status_codes_retries_test_data = [ (429, 4, 3), (401, 1, 0), (403, 1, 0), (404, 1, 0), (502, 1, 0), (503, 1, 0), (504, 1, 0), ] @patch("requests.Session.request") @patch(f"{jira.resilientsession.__name__}.time.sleep") @pytest.mark.parametrize( "status_code,expected_number_of_retries,expected_number_of_sleep_invocations", status_codes_retries_test_data, ) def test_status_codes_retries( mocked_sleep_method: Mock, mocked_request_method: Mock, status_code: int, expected_number_of_retries: int, expected_number_of_sleep_invocations: int, ): mocked_response: Response = Response() mocked_response.status_code = status_code mocked_response.headers["X-RateLimit-FillRate"] = "1" mocked_response.headers["X-RateLimit-Interval-Seconds"] = "1" mocked_response.headers["retry-after"] = "1" mocked_response.headers["X-RateLimit-Limit"] = "1" mocked_request_method.return_value = mocked_response session: jira.resilientsession.ResilientSession = ( jira.resilientsession.ResilientSession() ) with pytest.raises(JIRAError): session.get("mocked_url") assert mocked_request_method.call_count == expected_number_of_retries assert mocked_sleep_method.call_count == expected_number_of_sleep_invocations @patch("requests.Session.request") @patch(f"{jira.resilientsession.__name__}.time.sleep") @pytest.mark.parametrize( "status_code,expected_number_of_retries,expected_number_of_sleep_invocations", status_codes_retries_test_data, ) def test_status_codes_retries_no_headers( mocked_sleep_method: Mock, mocked_request_method: Mock, status_code: int, expected_number_of_retries: int, expected_number_of_sleep_invocations: int, ): mocked_response: Response = Response() mocked_response.status_code = status_code mocked_request_method.return_value = mocked_response session: jira.resilientsession.ResilientSession = ( jira.resilientsession.ResilientSession() ) with pytest.raises(JIRAError): session.get("mocked_url") assert mocked_request_method.call_count == expected_number_of_retries assert mocked_sleep_method.call_count == expected_number_of_sleep_invocations errors_parsing_test_data = [ (403, {"x-authentication-denied-reason": "err1"}, "", ["err1"]), (500, {}, "err1", ["err1"]), (500, {}, '{"message": "err1"}', ["err1"]), (500, {}, '{"errorMessages": "err1"}', ["err1"]), (500, {}, '{"errorMessages": ["err1", "err2"]}', ["err1", "err2"]), (500, {}, '{"errors": {"code1": "err1", "code2": "err2"}}', ["err1", "err2"]), ] @pytest.mark.parametrize( "status_code,headers,content,expected_errors", errors_parsing_test_data, ) def test_error_parsing(status_code, headers, content, expected_errors): mocked_response: Response = Response() mocked_response.status_code = status_code mocked_response.headers.update(headers) mocked_response._content = content.encode("utf-8") errors = parse_errors(mocked_response) assert errors == expected_errors error_msg = parse_error_msg(mocked_response) assert error_msg == ", ".join(expected_errors) def test_passthrough_class(): # GIVEN: The passthrough class and a dict of request args passthrough_class = jira.resilientsession.PassthroughRetryPrepare() my_kwargs = {"nice": "arguments"} # WHEN: the dict of request args are prepared # THEN: The exact same dict is returned assert passthrough_class.prepare(my_kwargs) is my_kwargs @patch("requests.Session.request") def test_unspecified_body_remains_unspecified(mocked_request_method: Mock): # Disable retries for this test. session = jira.resilientsession.ResilientSession(max_retries=0) # Data is not specified here. session.get(url="mocked_url") kwargs = mocked_request_method.call_args.kwargs assert "data" not in kwargs @patch("requests.Session.request") def test_nonempty_body_is_forwarded(mocked_request_method: Mock): # Disable retries for this test. session = jira.resilientsession.ResilientSession(max_retries=0) session.get(url="mocked_url", data={"some": "fake-data"}) kwargs = mocked_request_method.call_args.kwargs assert kwargs["data"] == '{"some": "fake-data"}' @patch("requests.Session.request") def test_with_requests_simple_timeout(mocked_request_method: Mock): # Disable retries for this test. session = jira.resilientsession.ResilientSession(max_retries=0, timeout=1) session.get(url="mocked_url", data={"some": "fake-data"}) kwargs = mocked_request_method.call_args.kwargs assert kwargs["data"] == '{"some": "fake-data"}' @patch("requests.Session.request") def test_with_requests_tuple_timeout(mocked_request_method: Mock): # Disable retries for this test. session = jira.resilientsession.ResilientSession(max_retries=0, timeout=(1, 3.5)) session.get(url="mocked_url", data={"some": "fake-data"}) kwargs = mocked_request_method.call_args.kwargs assert kwargs["data"] == '{"some": "fake-data"}' @patch("requests.Session.request") def test_verify_is_forwarded(mocked_request_method: Mock): # Disable retries for this test. session = jira.resilientsession.ResilientSession(max_retries=0) session.get(url="mocked_url", data={"some": "fake-data"}) kwargs = mocked_request_method.call_args.kwargs assert kwargs["verify"] == session.verify is True session.verify = False session.get(url="mocked_url", data={"some": "fake-data"}) kwargs = mocked_request_method.call_args.kwargs assert kwargs["verify"] == session.verify is False jira-3.5.2/tests/test_shell.py000066400000000000000000000067341444726022700163520ustar00rootroot00000000000000from __future__ import annotations import io import sys from unittest.mock import MagicMock, patch import pytest # noqa import requests # noqa import jira.jirashell as jirashell from jira import JIRA, Issue, JIRAError, Project, Role # noqa @pytest.fixture def testargs(): return ["jirashell", "-s", "http://localhost"] def test_unicode(requests_mock, capsys, testargs): """This functions tests that CLI tool does not throw an UnicodeDecodeError when it attempts to display some Unicode error message, which can happen when printing exceptions received from the remote HTTP server. """ requests_mock.register_uri( "GET", "http://localhost/rest/api/2/serverInfo", text="Δεν βρέθηκε", status_code=404, ) with patch.object(sys, "argv", testargs): jirashell.main() captured = capsys.readouterr() assert captured.err.startswith("JiraError HTTP 404") assert captured.out == "" @pytest.fixture def mock_keyring(): _keyring = {} def mock_set_password(server, username, password): _keyring[(server, username)] = password def mock_get_password(server, username): return _keyring.get((server, username), "") mock_kr = MagicMock( set_password=MagicMock(side_effect=mock_set_password), get_password=MagicMock(side_effect=mock_get_password), _keyring=_keyring, ) mocked_module = patch.object(jirashell, "keyring", new=mock_kr) yield mocked_module.start() mocked_module.stop() @pytest.mark.timeout(4) def test_no_password_try_keyring( requests_mock, capsys, testargs, mock_keyring, monkeypatch ): requests_mock.register_uri( "GET", "http://localhost/rest/api/2/serverInfo", status_code=200 ) # no password provided args = testargs + ["-u", "test@user"] with patch.object(sys, "argv", args): jirashell.main() assert len(requests_mock.request_history) == 0 captured = capsys.readouterr() assert "No password provided!" == captured.err.strip() assert "Getting password from keyring..." == captured.out.strip() assert mock_keyring._keyring == {} # password provided, don't save monkeypatch.setattr("sys.stdin", io.StringIO("n")) args = args + ["-p", "pass123"] with patch.object(sys, "argv", args): jirashell.main() assert len(requests_mock.request_history) == 4 captured = capsys.readouterr() assert captured.out.strip().startswith( "Would you like to remember password in OS keyring? (y/n)" ) assert mock_keyring._keyring == {} # password provided, save monkeypatch.setattr("sys.stdin", io.StringIO("y")) args = args + ["-p", "pass123"] with patch.object(sys, "argv", args): jirashell.main() assert len(requests_mock.request_history) == 8 captured = capsys.readouterr() assert captured.out.strip().startswith( "Would you like to remember password in OS keyring? (y/n)" ) assert mock_keyring._keyring == {("http://localhost", "test@user"): "pass123"} # user stored password args = testargs + ["-u", "test@user"] with patch.object(sys, "argv", args): jirashell.main() assert len(requests_mock.request_history) == 12 captured = capsys.readouterr() assert "Getting password from keyring..." == captured.out.strip() assert mock_keyring._keyring == {("http://localhost", "test@user"): "pass123"} jira-3.5.2/tests/tests.py000077500000000000000000000615761444726022700153560ustar00rootroot00000000000000#!/usr/bin/env python """This file contains tests that do not fit into any specific file yet. Feel free to make your own test file if appropriate. Refer to conftest.py for shared helper methods. resources/test_* : For tests related to resources test_* : For other tests of the non-resource elements of the jira package. """ from __future__ import annotations import logging import os import pickle from time import sleep from typing import cast from unittest import mock import pytest import requests from parameterized import parameterized from jira import JIRA, Issue, JIRAError from jira.client import ResultList from jira.resources import Dashboard, Resource, cls_for_resource from tests.conftest import JiraTestCase, allow_on_cloud, rndpassword LOGGER = logging.getLogger(__name__) class UniversalResourceTests(JiraTestCase): 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 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) # Class types are no longer equal, cls_for_resource() returns an Issue type # find() returns a Resource type. So we compare the raw json self.assertEqual(resource.raw, unpickled_instance.raw) def test_pickling_resource_class(self): resource = self.jira.find("issue/{0}", self.test_manager.project_b_issue1) pickled = pickle.dumps(resource) unpickled = pickle.loads(pickled) self.assertEqual(resource.key, unpickled.key) self.assertEqual(resource, unpickled) def test_pickling_issue_class(self): resource = self.test_manager.project_b_issue1_obj pickled = pickle.dumps(resource) unpickled = pickle.loads(pickled) self.assertEqual(resource.key, unpickled.key) self.assertEqual(resource, unpickled) def test_bad_attribute(self): resource = self.jira.find("issue/{0}", self.test_manager.project_b_issue1) with self.assertRaises(AttributeError): getattr(resource, "bogus123") def test_hashable(self): resource = self.jira.find("issue/{0}", self.test_manager.project_b_issue1) resource2 = self.jira.find("issue/{0}", self.test_manager.project_b_issue2) r1_hash = hash(resource) r2_hash = hash(resource2) assert r1_hash != r2_hash dict_of_resource = {resource: "hey", resource2: "peekaboo"} dict_of_resource.update({resource: "hey ho"}) assert len(dict_of_resource.keys()) == 2 assert {resource, resource2} == set(dict_of_resource.keys()) assert dict_of_resource[resource] == "hey ho" def test_hashable_issue_object(self): resource = self.test_manager.project_b_issue1_obj resource2 = self.test_manager.project_b_issue2_obj r1_hash = hash(resource) r2_hash = hash(resource2) assert r1_hash != r2_hash dict_of_resource = {resource: "hey", resource2: "peekaboo"} dict_of_resource.update({resource: "hey ho"}) assert len(dict_of_resource.keys()) == 2 assert {resource, resource2} == set(dict_of_resource.keys()) assert dict_of_resource[resource] == "hey ho" class ApplicationPropertiesTests(JiraTestCase): def test_application_properties(self): props = self.jira.application_properties() for p in props: self.assertIsInstance(p, dict) self.assertTrue( set(p.keys()).issuperset({"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"], "#172b4d") def test_set_application_property(self): prop = "jira.lf.favicon.hires.url" valid_value = "/jira-favicon-hires.png" invalid_value = "/invalid-jira-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") class FieldsTests(JiraTestCase): def test_fields(self): fields = self.jira.fields() self.assertGreater(len(fields), 10) class MyPermissionsServerTests(JiraTestCase): def setUp(self): super().setUp() 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) def test_my_permissions_by_issue(self): perms = self.jira.my_permissions(issueKey=self.issue_1) self.assertGreaterEqual(len(perms["permissions"]), 10) perms = self.jira.my_permissions( issueId=self.test_manager.project_b_issue1_obj.id ) self.assertGreaterEqual(len(perms["permissions"]), 10) @allow_on_cloud class MyPermissionsCloudTests(JiraTestCase): def setUp(self): super().setUp() if not self.jira._is_cloud: self.skipTest("cloud only test class") self.issue_1 = self.test_manager.project_b_issue1 self.permission_keys = "BROWSE_PROJECTS,CREATE_ISSUES,ADMINISTER_PROJECTS" def test_my_permissions(self): perms = self.jira.my_permissions(permissions=self.permission_keys) self.assertEqual(len(perms["permissions"]), 3) def test_my_permissions_by_project(self): perms = self.jira.my_permissions( projectKey=self.test_manager.project_a, permissions=self.permission_keys ) self.assertEqual(len(perms["permissions"]), 3) perms = self.jira.my_permissions( projectId=self.test_manager.project_a_id, permissions=self.permission_keys ) self.assertEqual(len(perms["permissions"]), 3) def test_my_permissions_by_issue(self): perms = self.jira.my_permissions( issueKey=self.issue_1, permissions=self.permission_keys ) self.assertEqual(len(perms["permissions"]), 3) perms = self.jira.my_permissions( issueId=self.test_manager.project_b_issue1_obj.id, permissions=self.permission_keys, ) self.assertEqual(len(perms["permissions"]), 3) def test_missing_required_param_my_permissions_raises_exception(self): with self.assertRaises(JIRAError): self.jira.my_permissions() def test_invalid_param_my_permissions_raises_exception(self): with self.assertRaises(JIRAError): self.jira.my_permissions("INVALID_PERMISSION") class SearchTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue = self.test_manager.project_b_issue1 def test_search_issues(self): issues = self.jira.search_issues(f"project={self.project_b}") issues = cast(ResultList[Issue], issues) self.assertLessEqual(len(issues), 50) # default maxResults for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) def test_search_issues_async(self): original_val = self.jira._options["async"] try: self.jira._options["async"] = True issues = self.jira.search_issues( f"project={self.project_b}", maxResults=False ) issues = cast(ResultList[Issue], issues) self.assertEqual(len(issues), issues.total) for issue in issues: self.assertTrue(issue.key.startswith(self.project_b)) finally: self.jira._options["async"] = original_val def test_search_issues_maxresults(self): issues = self.jira.search_issues(f"project={self.project_b}", maxResults=10) self.assertLessEqual(len(issues), 10) def test_search_issues_startat(self): issues = self.jira.search_issues( f"project={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(f"key={self.issue}", fields="summary,comment") issues = cast(ResultList[Issue], issues) 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_expand(self): issues = self.jira.search_issues(f"key={self.issue}", expand="changelog") issues = cast(ResultList[Issue], issues) # 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) class ServerInfoTests(JiraTestCase): def test_server_info(self): server_info = self.jira.server_info() self.assertIn("baseUrl", server_info) self.assertIn("version", server_info) class OtherTests(JiraTestCase): def setUp(self) -> None: pass # we don't need Jira instance here def test_session_invalid_login(self): try: JIRA( "https://jira.atlassian.com", basic_auth=("xxx", "xxx"), validate=True, logging=False, ) except Exception as e: self.assertIsInstance(e, JIRAError) e = cast(JIRAError, e) # help mypy # 20161010: jira cloud returns 500 assert e.status_code in (401, 500, 403) str(JIRAError) # to see that this does not raise an exception return assert False class SessionTests(JiraTestCase): def test_session(self): user = self.jira.session() self.assertIsNotNone(user.raw["self"]) self.assertIsNotNone(user.raw["name"]) def test_session_with_no_logged_in_user_raises(self): anon_jira = JIRA("https://jira.atlassian.com", logging=False) self.assertRaises(JIRAError, anon_jira.session) 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.") MIMICKED_BACKEND_BATCH_SIZE = 10 class AsyncTests(JiraTestCase): def setUp(self): self.jira = JIRA( "https://jira.atlassian.com", logging=False, async_=True, validate=False, get_server_info=False, ) @parameterized.expand( [ ( 0, 26, {Issue: None}, False, ), # original behaviour, fetch all with jira's original return size (0, 26, {Issue: 20}, False), # set batch size to 20 (5, 26, {Issue: 20}, False), # test start_at (5, 26, {Issue: 20}, 50), # test maxResults set (one request) ] ) def test_fetch_pages( self, start_at: int, total: int, default_batch_sizes: dict, max_results: int ): """Tests that the JIRA._fetch_pages method works as expected.""" params = {"startAt": 0} self.jira._options["default_batch_size"] = default_batch_sizes batch_size = self.jira._get_batch_size(Issue) expected_calls = _calculate_calls_for_fetch_pages( "https://jira.atlassian.com/rest/api/2/search", start_at, total, max_results, batch_size, MIMICKED_BACKEND_BATCH_SIZE, ) batch_size = batch_size or MIMICKED_BACKEND_BATCH_SIZE expected_results = [] for i in range(0, total): result = _create_issue_result_json(i, f"summary {i}", key=f"KEY-{i}") expected_results.append(result) if not max_results: mocked_api_results = [] for i in range(start_at, total, batch_size): mocked_api_result = _create_issue_search_results_json( expected_results[i : i + batch_size], max_results=batch_size, total=total, ) mocked_api_results.append(mocked_api_result) else: mocked_api_results = [ _create_issue_search_results_json( expected_results[start_at : max_results + start_at], max_results=max_results, total=total, ) ] mock_session = mock.Mock(name="mock_session") responses = mock.Mock(name="responses") responses.content = "_filler_" responses.json.side_effect = mocked_api_results responses.status_code = 200 mock_session.request.return_value = responses mock_session.get.return_value = responses self.jira._session.close() self.jira._session = mock_session items = self.jira._fetch_pages( Issue, "issues", "search", start_at, max_results, params=params ) actual_calls = [[kall[1], kall[2]] for kall in self.jira._session.method_calls] self.assertEqual(actual_calls, expected_calls) self.assertEqual(len(items), total - start_at) self.assertEqual( {item.key for item in items}, {expected_r["key"] for expected_r in expected_results[start_at:]}, ) @pytest.mark.parametrize( "default_batch_sizes, item_type, expected", [ ({Issue: 2}, Issue, 2), ({Resource: 1}, Resource, 1), ( {Resource: 1, Issue: None}, Issue, None, ), ({Resource: 1}, Dashboard, 1), ({}, Issue, 100), ({}, Resource, 100), ], ids=[ "modify Issue default", "modify Resource default", "let backend decide for Issue", "fallback", "default for Issue", "default value for everything else", ], ) def test_get_batch_size(default_batch_sizes, item_type, expected, no_fields): jira = JIRA(default_batch_sizes=default_batch_sizes, get_server_info=False) assert jira._get_batch_size(item_type) == expected def _create_issue_result_json(issue_id, summary, key, **kwargs): """Returns a minimal json object for an issue.""" return { "id": f"{issue_id}", "summary": summary, "key": key, "self": kwargs.get("self", f"http://example.com/{issue_id}"), } def _create_issue_search_results_json(issues, **kwargs): """Returns a minimal json object for Jira issue search results.""" return { "startAt": kwargs.get("start_at", 0), "maxResults": kwargs.get("max_results", 50), "total": kwargs.get("total", len(issues)), "issues": issues, } def _calculate_calls_for_fetch_pages( url: str, start_at: int, total: int, max_results: int, batch_size: int | None, default: int | None = 10, ): """Returns expected query parameters for specified search-issues arguments.""" if not max_results: call_list = [] if batch_size is None: # for the first request with batch-size is `None` we specifically cannot/don't want to set it but let # the server specify it (here we mimic a server-default of 10 issues per batch). call_ = [(url,), {"params": {"startAt": start_at}}] call_list.append(call_) start_at += default batch_size = default for index, start_at in enumerate(range(start_at, total, batch_size)): call_ = [ (url,), {"params": {"startAt": start_at, "maxResults": batch_size}}, ] call_list.append(call_) else: call_list = [ [(url,), {"params": {"startAt": start_at, "maxResults": max_results}}] ] return call_list DEFAULT_NEW_REMOTE_LINK_OBJECT = {"url": "http://google.com", "title": "googlicious!"} class ClientRemoteLinkTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.issue_key = self.test_manager.project_b_issue1 def test_delete_remote_link_by_internal_id(self): link = self.jira.add_remote_link( self.issue_key, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, ) _id = link.id self.jira.delete_remote_link(self.issue_key, internal_id=_id) self.assertRaises(JIRAError, self.jira.remote_link, self.issue_key, _id) def test_delete_remote_link_by_global_id(self): link = self.jira.add_remote_link( self.issue_key, destination=DEFAULT_NEW_REMOTE_LINK_OBJECT, globalId="python-test:story.of.sasquatch.riding", ) _id = link.id self.jira.delete_remote_link( self.issue_key, global_id="python-test:story.of.sasquatch.riding" ) self.assertRaises(JIRAError, self.jira.remote_link, self.issue_key, _id) def test_delete_remote_link_with_invalid_args(self): self.assertRaises(ValueError, self.jira.delete_remote_link, self.issue_key) class WebsudoTests(JiraTestCase): def test_kill_websudo(self): self.jira.kill_websudo() # def test_kill_websudo_without_login_raises(self): # self.assertRaises(ConnectionError, JIRA) class UserAdministrationTests(JiraTestCase): def setUp(self): JiraTestCase.setUp(self) self.test_username = f"test_{self.test_manager.project_a}" self.test_email = f"{self.test_username}@example.com" self.test_password = rndpassword() self.test_groupname = f"testGroupFor_{self.test_manager.project_a}" def _skip_pycontribs_instance(self): pytest.skip( "The current ci jira admin user for " "https://pycontribs.atlassian.net lacks " "permission to modify users." ) def _should_skip_for_pycontribs_instance(self): # return True return self.test_manager.CI_JIRA_ADMIN == "ci-admin" and ( self.test_manager.CI_JIRA_URL == "https://pycontribs.atlassian.net" ) def test_add_and_remove_user(self): if self._should_skip_for_pycontribs_instance(): self._skip_pycontribs_instance() try: self.jira.delete_user(self.test_username) except JIRAError as e: print(e) # 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." ) # test creating users with no application access (used for Service Desk) result = self.jira.add_user( self.test_username, self.test_email, password=self.test_password, application_keys=["jira-software"], ) assert result, True result = self.jira.delete_user(self.test_username) assert result, True def test_add_group(self): if self._should_skip_for_pycontribs_instance(): self._skip_pycontribs_instance() try: self.jira.remove_group(self.test_groupname) except JIRAError: pass sleep(2) # avoid 500 errors 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): if self._should_skip_for_pycontribs_instance(): self._skip_pycontribs_instance() try: self.jira.add_group(self.test_groupname) sleep(1) # avoid 400 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.", ) 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): if self._should_skip_for_pycontribs_instance(): self._skip_pycontribs_instance() 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(JiraTestCase): def setUp(self) -> None: pass # Jira Instance not required def test_jirashell_command_exists(self): result = os.system("jirashell --help") self.assertEqual(result, 0) jira-3.5.2/tox.ini000066400000000000000000000071631444726022700140000ustar00rootroot00000000000000[tox] minversion = 4.0 isolated_build = True requires = # plugins disabled until they gets compatible with tox v4 # tox-extra # tox-pyenv envlist = py311 py310 py39 py38 ignore_basepython_conflict = True skip_missing_interpreters = True skipdist = True [gh-actions] python = 3.8: py38 3.9: py39 3.10: py310 3.11: py311 [testenv] usedevelop = True # hide deps from stdout https://github.com/tox-dev/tox/issues/601 # list_dependencies_command=echo extras = cli opt test sitepackages=False commands= git clean -xdf jira tests python -m pip check python make_local_jira_user.py python -m pytest {posargs} setenv = PIP_CONSTRAINT={toxinidir}/constraints.txt PIP_LOG={envdir}/pip.log PIP_DISABLE_PIP_VERSION_CHECK=1 # Avoid 2020-01-01 warnings: https://github.com/pypa/pip/issues/6207 PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command CI_JIRA_URL=http://localhost:2990/jira CI_JIRA_ADMIN=admin CI_JIRA_ADMIN_PASSWORD=admin CI_JIRA_USER=jira_user CI_JIRA_USER_FULL_NAME=Newly Created CI User CI_JIRA_USER_PASSWORD=jira CI_JIRA_ISSUE=Task passenv = CI CI_JIRA_* CURL_CA_BUNDLE PIP_* REQUESTS_CA_BUNDLE SSL_CERT_FILE TWINE_* XDG_CACHE_HOME # For Windows users, getpass.get_user() needs USERNAME USERNAME allowlist_externals = git sh [testenv:deps] description = Update dependency lock files # Force it to use oldest supported version of python or we would lose ability # to get pinning correctly. basepython = python3.8 deps = pip-tools >= 6.4.0 pre-commit >= 2.13.0 commands = pip-compile --upgrade -o constraints.txt setup.cfg --extra cli --extra docs --extra opt --extra async --extra test --strip-extras {envpython} -m pre_commit autoupdate [testenv:docs] extras = cli docs # changedir=docs usedevelop = False skipdist = False setenv = PYTHONHTTPSVERIFY=0 commands = sphinx-build \ -a -n -v -W --keep-going \ -b html --color \ -d "{toxworkdir}/docs_doctree" \ docs/ "{toxworkdir}/docs_out" # Print out the output docs dir and a way to serve html: python -c \ 'import pathlib; '\ 'docs_dir = pathlib.Path(r"{toxworkdir}") / "docs_out"; index_file = docs_dir / "index.html"; print(f"\nDocumentation available under `file://\{index_file\}`\n\nTo serve docs, use `python3 -m http.server --directory \{docs_dir\} 0`\n")' [testenv:packaging] basepython = python3 description = Build package, verify metadata, install package and assert behavior when ansible is missing. deps = build >= 0.7.0 twine skip_install = true # Ref: https://twitter.com/di_codes/status/1044358639081975813 commands = # build wheel and sdist using PEP-517 {envpython} -c 'import os.path, shutil, sys; \ dist_dir = os.path.join(r"{toxinidir}", "dist"); \ os.path.isdir(dist_dir) or sys.exit(0); \ print("Removing \{!s\} contents...".format(dist_dir), file=sys.stderr); \ shutil.rmtree(dist_dir)' {envpython} -m build \ --outdir {toxinidir}/dist/ \ {toxinidir} # Validate metadata using twine twine check --strict {toxinidir}/dist/* # Install the wheel sh -c "python3 -m pip install {toxinidir}/dist/*.whl" # Check if cli was installed jirashell --help # Uninstall the wheel {envpython} -m pip uninstall -y jira [testenv:lint] deps = pre-commit>=1.17.0 commands= python -m pre_commit run --color=always {posargs:--all} setenv = PIP_CONSTRAINT= skip_install = true usedevelop = false [testenv:maintenance] # this will obliterate your jira instance, used to clean the CI server commands= python examples/maintenance.py