From cbbf1be03ff34acc411837c5a7ee8be2345910e7 Mon Sep 17 00:00:00 2001 From: rnetser Date: Mon, 16 Mar 2026 13:33:30 +0200 Subject: [PATCH] feat(jira): add cloud Jira authentication support Add --cloud flag and --user option to support cloud Jira instances using basic_auth (email + API token) alongside existing server token_auth. - Add --cloud CLI flag (tri-state: None/True/False) with config fallback - Add --user CLI option with JIRA_USER env var and config fallback - Branch JIRA connection: basic_auth for cloud, token_auth for server - Validate user is required when cloud mode is enabled - Add comprehensive tests covering all cloud scenarios --- apps/jira_utils/jira_information.py | 40 ++++- config.example.yaml | 2 + tests/jira_utils/test_jira_cfg_file.yaml | 2 + tests/jira_utils/test_jira_utils.py | 189 +++++++++++++++++++++++ 4 files changed, 229 insertions(+), 4 deletions(-) diff --git a/apps/jira_utils/jira_information.py b/apps/jira_utils/jira_information.py index ed5b766..db06e7a 100644 --- a/apps/jira_utils/jira_information.py +++ b/apps/jira_utils/jira_information.py @@ -146,16 +146,24 @@ def process_jira_command_line_config_file( version_string_not_targeted_jiras: str, target_versions: list[str], skip_projects: list[str], + user: str, + cloud: bool | None, ) -> dict[str, Any]: # Process all the arguments passed from command line or config file or environment variable config_dict = get_util_config(util_name="pyutils-jira", config_file_path=config_file_path) url = url or config_dict.get("url", "") token = token or config_dict.get("token", "") + user = user or config_dict.get("user", "") + cloud = cloud if cloud is not None else config_dict.get("cloud", False) if not (url and token): LOGGER.error("Jira url and token are required.") sys.exit(1) + if cloud and not user: + LOGGER.error("Jira user is required for cloud Jira.") + sys.exit(1) + return { "url": url, "token": token, @@ -166,6 +174,8 @@ def process_jira_command_line_config_file( ), "target_versions": target_versions or config_dict.get("target_versions", []), "skip_project_ids": skip_projects or config_dict.get("skip_project_ids", []), + "user": user, + "cloud": cloud, } @@ -197,6 +207,12 @@ def process_jira_command_line_config_file( type=click.STRING, default=os.getenv("JIRA_TOKEN"), ) +@click.option( + "--user", + help="Provide the Jira user email (required for cloud Jira).", + type=click.STRING, + default=os.getenv("JIRA_USER"), +) @click.option( "--issue-pattern", help="Provide the regex for Jira ids", @@ -219,16 +235,24 @@ def process_jira_command_line_config_file( default="vfuture", ) @click.option("--verbose", default=False, is_flag=True) +@click.option( + "--cloud", + help="Use cloud Jira authentication (basic_auth with user email and API token).", + is_flag=True, + default=None, +) def get_jira_mismatch( config_file_path: str, target_versions: list[str], url: str, token: str, + user: str, skip_projects: list[str], resolved_statuses: list[str], issue_pattern: str, version_string_not_targeted_jiras: str, verbose: bool, + cloud: bool | None, ) -> None: LOGGER.setLevel(logging.DEBUG if verbose else logging.INFO) if not (config_file_path or (token and url)): @@ -245,12 +269,20 @@ def get_jira_mismatch( skip_projects=skip_projects, version_string_not_targeted_jiras=version_string_not_targeted_jiras, target_versions=target_versions, + user=user, + cloud=cloud, ) - jira_obj = JIRA( - token_auth=jira_config_dict["token"], - options={"server": jira_config_dict["url"]}, - ) + if jira_config_dict["cloud"]: + jira_obj = JIRA( + server=jira_config_dict["url"], + basic_auth=(jira_config_dict["user"], jira_config_dict["token"]), + ) + else: + jira_obj = JIRA( + token_auth=jira_config_dict["token"], + options={"server": jira_config_dict["url"]}, + ) jira_error: dict[str, str] = {} if jira_id_dict := get_jiras_from_python_files( diff --git a/config.example.yaml b/config.example.yaml index f9972cf..d6c86be 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -12,6 +12,8 @@ pyutils-polarion-set-automated: pyutils-jira: url: token: + user: + cloud: false resolved_statuses: - verified - release pending diff --git a/tests/jira_utils/test_jira_cfg_file.yaml b/tests/jira_utils/test_jira_cfg_file.yaml index dcfa236..7b7bb07 100644 --- a/tests/jira_utils/test_jira_cfg_file.yaml +++ b/tests/jira_utils/test_jira_cfg_file.yaml @@ -1,6 +1,8 @@ pyutils-jira: url: "https://example.com" token: "" + user: "test@example.com" + cloud: false resolved_statuses: - "RESOLVED" issue_pattern: "*" diff --git a/tests/jira_utils/test_jira_utils.py b/tests/jira_utils/test_jira_utils.py index 56e8133..378b5dd 100644 --- a/tests/jira_utils/test_jira_utils.py +++ b/tests/jira_utils/test_jira_utils.py @@ -49,6 +49,8 @@ def test_process_jira_command_line_config_file_valid_config(mocker): "version_string_not_targeted_jiras": version_string_not_targeted_jiras, "target_versions": target_versions, "skip_project_ids": skip_projects, + "user": "", + "cloud": False, }, ) result = process_jira_command_line_config_file( @@ -60,6 +62,8 @@ def test_process_jira_command_line_config_file_valid_config(mocker): version_string_not_targeted_jiras, target_versions, skip_projects, + user="", + cloud=False, ) assert result == { "url": url, @@ -69,10 +73,195 @@ def test_process_jira_command_line_config_file_valid_config(mocker): "not_targeted_version_str": version_string_not_targeted_jiras, "target_versions": target_versions, "skip_project_ids": skip_projects, + "user": "", + "cloud": False, } mock_get_util_config.assert_called_once() +def test_process_jira_command_line_config_file_cloud_valid(mocker): + config_file_path = "/path/to/config" + url = "https://example.com" + token = "1234567890" + user = "test@example.com" + + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={ + "url": url, + "token": token, + "cloud": True, + "user": user, + }, + ) + result = process_jira_command_line_config_file( + config_file_path=config_file_path, + url=url, + token=token, + issue_pattern="*", + resolved_statuses=["RESOLVED"], + version_string_not_targeted_jiras="v1.*", + target_versions=["v2"], + skip_projects=[], + user=user, + cloud=True, + ) + assert result["cloud"] is True + assert result["user"] == user + + +def test_process_jira_command_line_config_file_cloud_missing_user(mocker): + config_file_path = "/path/to/config" + url = "https://example.com" + token = "1234567890" + + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={ + "url": url, + "token": token, + }, + ) + with pytest.raises(SystemExit): + process_jira_command_line_config_file( + config_file_path=config_file_path, + url=url, + token=token, + issue_pattern="*", + resolved_statuses=["RESOLVED"], + version_string_not_targeted_jiras="v1.*", + target_versions=["v2"], + skip_projects=[], + user="", + cloud=True, + ) + + +def test_process_cloud_from_config_when_cli_not_passed(mocker): + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={"url": "https://example.com", "token": "tok", "cloud": True, "user": "u@e.com"}, + ) + result = process_jira_command_line_config_file( + config_file_path="/path", + url="https://example.com", + token="tok", + issue_pattern="*", + resolved_statuses=[], + version_string_not_targeted_jiras="", + target_versions=[], + skip_projects=[], + user="u@e.com", + cloud=None, + ) + assert result["cloud"] is True + assert result["user"] == "u@e.com" + + +def test_process_cloud_defaults_false_when_not_in_config(mocker): + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={"url": "https://example.com", "token": "tok"}, + ) + result = process_jira_command_line_config_file( + config_file_path="/path", + url="https://example.com", + token="tok", + issue_pattern="*", + resolved_statuses=[], + version_string_not_targeted_jiras="", + target_versions=[], + skip_projects=[], + user="", + cloud=None, + ) + assert result["cloud"] is False + + +def test_process_cloud_from_config_missing_user_exits(mocker): + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={"url": "https://example.com", "token": "tok", "cloud": True}, + ) + with pytest.raises(SystemExit): + process_jira_command_line_config_file( + config_file_path="/path", + url="https://example.com", + token="tok", + issue_pattern="*", + resolved_statuses=[], + version_string_not_targeted_jiras="", + target_versions=[], + skip_projects=[], + user="", + cloud=None, + ) + + +def test_process_user_from_config_fallback(mocker): + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={"url": "https://example.com", "token": "tok", "user": "config@example.com", "cloud": True}, + ) + result = process_jira_command_line_config_file( + config_file_path="/path", + url="https://example.com", + token="tok", + issue_pattern="*", + resolved_statuses=[], + version_string_not_targeted_jiras="", + target_versions=[], + skip_projects=[], + user="", + cloud=True, + ) + assert result["user"] == "config@example.com" + assert result["cloud"] is True + + +def test_process_cloud_false_explicit_ignores_config(mocker): + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={"url": "https://example.com", "token": "tok", "cloud": True}, + ) + result = process_jira_command_line_config_file( + config_file_path="/path", + url="https://example.com", + token="tok", + issue_pattern="*", + resolved_statuses=[], + version_string_not_targeted_jiras="", + target_versions=[], + skip_projects=[], + user="", + cloud=False, + ) + assert result["cloud"] is False + + +def test_process_server_mode_default(mocker): + mocker.patch( + "apps.jira_utils.jira_information.get_util_config", + return_value={"url": "https://example.com", "token": "tok"}, + ) + result = process_jira_command_line_config_file( + config_file_path="/path", + url="https://example.com", + token="tok", + issue_pattern="([A-Z]+-[0-9]+)", + resolved_statuses=["resolved"], + version_string_not_targeted_jiras="vfuture", + target_versions=[], + skip_projects=[], + user="", + cloud=False, + ) + assert result["cloud"] is False + assert result["user"] == "" + assert result["url"] == "https://example.com" + assert result["token"] == "tok" + + @pytest.mark.parametrize( "test_params", [