diff --git a/.github/workflows/jira_ci.yml b/.github/workflows/jira_ci.yml new file mode 100644 index 000000000..7acfe610a --- /dev/null +++ b/.github/workflows/jira_ci.yml @@ -0,0 +1,24 @@ +name: Jira 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 }} diff --git a/.github/workflows/jira_cloud_ci.yml b/.github/workflows/jira_cloud_ci.yml new file mode 100644 index 000000000..8ca913005 --- /dev/null +++ b/.github/workflows/jira_cloud_ci.yml @@ -0,0 +1,70 @@ +name: Jira Cloud CI + +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: + name: ${{ matrix.os }} / Python ${{ matrix.python-version }} + runs-on: ${{ matrix.os }}-latest + strategy: + matrix: + os: [Ubuntu] + # We only test a single version to prevent concurrent + # running of tests influencing one another + python-version: [3.8] + + steps: + - uses: actions/checkout@master + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + 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@v2 + 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@v1.0.15 + with: + file: ./coverage.xml + name: ${{ runner.os }}-${{ matrix.python-version }}-Cloud diff --git a/.github/workflows/jira_server_ci.yml b/.github/workflows/jira_server_ci.yml index 515c803f3..dd130ea33 100644 --- a/.github/workflows/jira_server_ci.yml +++ b/.github/workflows/jira_server_ci.yml @@ -1,14 +1,8 @@ name: Jira Server CI on: - # Trigger the workflow on push or pull request, - # but only for the main branch - push: - branches: - - main - pull_request: - branches: - - main + workflow_call: + workflow_dispatch: jobs: test: diff --git a/jira/client.py b/jira/client.py index 0a5db156d..1da7a64fb 100644 --- a/jira/client.py +++ b/jira/client.py @@ -3790,10 +3790,14 @@ def backup_download(self, filename: str = None): self.log.error(ioe) return None - def current_user(self, field: str = "key") -> str: + def current_user(self, field: Optional[str] = None) -> str: """Returns the username or emailAddress 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 "key" + Returns: str """ @@ -3805,6 +3809,9 @@ def current_user(self, field: str = "key") -> str: r_json: Dict[str, str] = json_loads(r) self._myself = r_json + if field is None: + field = "accountId" if self._is_cloud else "key" + return self._myself[field] def delete_project(self, pid: Union[str, Project]) -> Optional[bool]: @@ -4004,31 +4011,31 @@ def create_project( ps_list: List[Dict[str, Any]] - if not permissionScheme: + if permissionScheme is None: ps_list = self.permissionschemes() for sec in ps_list: if sec["name"] == "Default Permission Scheme": permissionScheme = sec["id"] - break - if not permissionScheme: + break + if permissionScheme is None and ps_list: permissionScheme = ps_list[0]["id"] - if not issueSecurityScheme: + 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 not issueSecurityScheme and ps_list: + break + if issueSecurityScheme is None and ps_list: issueSecurityScheme = ps_list[0]["id"] - if not 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 not projectCategory and ps_list: + 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. @@ -4038,7 +4045,9 @@ def create_project( if not template_name: # https://confluence.atlassian.com/jirakb/creating-projects-via-rest-api-in-jira-963651978.html template_key = ( - "com.pyxis.greenhopper.jira:basic-software-development-template" + "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 @@ -4101,8 +4110,7 @@ def create_project( "key": key, "projectTypeKey": ptype, "projectTemplateKey": template_key, - "lead": assignee, - # "leadAccountId": assignee, + "leadAccountId" if self._is_cloud else "lead": assignee, "assigneeType": "PROJECT_LEAD", "description": "", # "avatarId": 13946, diff --git a/make_local_jira_user.py b/make_local_jira_user.py index 45c8d8f91..781539551 100644 --- a/make_local_jira_user.py +++ b/make_local_jira_user.py @@ -1,6 +1,7 @@ """Attempts to create a test user, as the empty JIRA instance isn't provisioned with one. """ +import sys import time from os import environ @@ -29,6 +30,10 @@ def add_user_to_jira(): 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( diff --git a/setup.cfg b/setup.cfg index f24f5927d..9d10ec427 100644 --- a/setup.cfg +++ b/setup.cfg @@ -160,5 +160,8 @@ timeout = 80 filterwarnings = ignore::pytest.PytestWarning +markers = + allow_on_cloud: opt in for the test to run on Jira Cloud + [mypy] python_version = 3.6 diff --git a/tests/conftest.py b/tests/conftest.py index bc81c0a9d..a8e820a71 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,9 +11,9 @@ from typing import Any, Dict import pytest -from flaky import flaky 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") @@ -21,32 +21,11 @@ LOGGER = logging.getLogger(__name__) -OAUTH = False -CONSUMER_KEY = "oauth-consumer" -KEY_CERT_FILE = "/home/bspeakmon/src/atlassian-oauth-examples/rsa.pem" -KEY_CERT_DATA = None -try: - with open(KEY_CERT_FILE) as cert: - KEY_CERT_DATA = cert.read() - OAUTH = True -except Exception: - OAUTH = False - - -ON_CUSTOM_JIRA = "CI_JIRA_URL" in os.environ - - -not_on_custom_jira_instance = pytest.mark.skipif( - ON_CUSTOM_JIRA, reason="Not applicable for custom Jira instance" -) -if ON_CUSTOM_JIRA: - LOGGER.info("Picked up custom Jira engine.") - +allow_on_cloud = pytest.mark.allow_on_cloud broken_test = pytest.mark.xfail -@flaky # all have default flaki-ness class JiraTestCase(unittest.TestCase): """Test case for all Jira tests. @@ -71,9 +50,19 @@ class JiraTestCase(unittest.TestCase): def setUp(self) -> None: """ This is called before each test. If you want to add more for your tests, - Run `JiraTestCase.setUp(self) in your custom setUp() to obtain these. + Run `super().setUp() in your custom setUp() to obtain these. """ - self.test_manager = JiraTestManager() + + 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 @@ -81,6 +70,11 @@ def setUp(self) -> None: self.project_b = self.test_manager.project_b self.project_a = self.test_manager.project_a + @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)) @@ -120,20 +114,23 @@ class JiraTestManager: 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="Server"): + 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 == "Cloud": + 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() @@ -143,10 +140,8 @@ def __init__(self, jira_hosted_type="Server"): "validate": True, "max_retries": self.max_retries, } - if OAUTH: - self.set_oauth_logins() - else: - self.set_basic_auth_logins(**jira_class_kwargs) + + self.set_basic_auth_logins(**jira_class_kwargs) if not self.jira_admin.current_user(): self.initialized = True @@ -158,16 +153,21 @@ def __init__(self, jira_hosted_type="Server"): if not hasattr(self, "jira_normal") or not hasattr(self, "jira_admin"): pytest.exit("FATAL: WTF!?") - 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] + 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 = "ci-admin" - self.CI_JIRA_ADMIN_PASSWORD = "sd4s3dgec5fhg4tfsds3434" - self.CI_JIRA_USER = "ci-user" - self.CI_JIRA_USER_PASSWORD = "sd4s3dgec5fhg4tfsds3434" + 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"] @@ -177,34 +177,6 @@ def set_jira_server_details(self): self.CI_JIRA_USER_PASSWORD = os.environ["CI_JIRA_USER_PASSWORD"] self.CI_JIRA_ISSUE = os.environ.get("CI_JIRA_ISSUE", "Bug") - def set_oauth_logins(self): - self.jira_admin = JIRA( - oauth={ - "access_token": "hTxcwsbUQiFuFALf7KZHDaeAJIo3tLUK", - "access_token_secret": "aNCLQFP3ORNU6WY7HQISbqbhf0UudDAf", - "consumer_key": CONSUMER_KEY, - "key_cert": KEY_CERT_DATA, - } - ) - self.jira_sysadmin = JIRA( - oauth={ - "access_token": "4ul1ETSFo7ybbIxAxzyRal39cTrwEGFv", - "access_token_secret": "K83jBZnjnuVRcfjBflrKyThJa0KSjSs2", - "consumer_key": CONSUMER_KEY, - "key_cert": KEY_CERT_DATA, - }, - logging=False, - max_retries=self.max_retries, - ) - self.jira_normal = JIRA( - oauth={ - "access_token": "ZVDgYDyIQqJY8IFlQ446jZaURIz5ECiB", - "access_token_secret": "5WbLBybPDg1lqqyFjyXSCsCtAWTwz1eD", - "consumer_key": CONSUMER_KEY, - "key_cert": KEY_CERT_DATA, - } - ) - def set_basic_auth_logins(self, **jira_class_kwargs): if self.CI_JIRA_ADMIN: self.jira_admin = JIRA( @@ -220,10 +192,67 @@ def set_basic_auth_logins(self, **jira_class_kwargs): **jira_class_kwargs, ) else: - # Setup some un-authenticated users - self.jira_admin = JIRA(self.CI_JIRA_URL, **jira_class_kwargs) - self.jira_sysadmin = JIRA(self.CI_JIRA_URL, **jira_class_kwargs) - self.jira_normal = JIRA(self.CI_JIRA_URL, **jira_class_kwargs) + 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""" @@ -259,72 +288,30 @@ def create_some_data(self): self.project_sd, ) - # TODO(ssbarnea): find a way to prevent SecurityTokenMissing for On Demand - # https://jira.atlassian.com/browse/JRA-39153 - try: - self.jira_admin.project(self.project_a) - except Exception as e: - LOGGER.warning(e) - else: - try: - self.jira_admin.delete_project(self.project_a) - except Exception as e: - LOGGER.warning("Failed to delete %s\n%s", self.project_a, e) - - try: - self.jira_admin.project(self.project_b) - except Exception as e: - LOGGER.warning(e) - else: - try: - self.jira_admin.delete_project(self.project_b) - except Exception as e: - LOGGER.warning("Failed to delete %s\n%s", self.project_b, e) - - # wait for the project to be deleted - for _ in range(1, 20): - try: - self.jira_admin.project(self.project_b) - except Exception: - break - print("Warning: Project not deleted yet....") - sleep(2) - - for _ in range(6): - try: - if self.jira_admin.create_project(self.project_a, self.project_a_name): - break - except Exception as e: - if "A project with that name already exists" not in str(e): - raise e - self.project_a_id = self.jira_admin.project(self.project_a).id - self.jira_admin.create_project(self.project_b, self.project_b_name) + 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 + ) - try: - self.jira_admin.create_project(self.project_b, self.project_b_name) - except Exception: - # we care only for the project to exist - pass sleep(1) # keep it here as often Jira will report the # project as missing even after is created + + project_b_issue_kwargs = { + "project": self.project_b, + "issuetype": {"name": self.CI_JIRA_ISSUE}, + } self.project_b_issue1_obj = self.jira_admin.create_issue( - project=self.project_b, - summary="issue 1 from %s" % self.project_b, - issuetype=self.CI_JIRA_ISSUE, + summary="issue 1 from %s" % 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( - project=self.project_b, - summary="issue 2 from %s" % self.project_b, - issuetype={"name": self.CI_JIRA_ISSUE}, + summary="issue 2 from %s" % 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( - project=self.project_b, - summary="issue 3 from %s" % self.project_b, - issuetype={"name": self.CI_JIRA_ISSUE}, + summary="issue 3 from %s" % self.project_b, **project_b_issue_kwargs ) self.project_b_issue3 = self.project_b_issue3_obj.key diff --git a/tests/resources/test_group.py b/tests/resources/test_group.py index 8be77dd2a..2ad4185a2 100644 --- a/tests/resources/test_group.py +++ b/tests/resources/test_group.py @@ -1,15 +1,22 @@ -from tests.conftest import JiraTestCase +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("jira-administrators") - self.assertEqual(group.name, "jira-administrators") + 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("jira-administrators") + groups = self.jira.groups(self.group_name) self.assertGreater(len(groups), 0)