Skip to content

Commit dbb7226

Browse files
authored
feat(jira): add cloud Jira authentication support (#272)
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
1 parent 692865b commit dbb7226

4 files changed

Lines changed: 229 additions & 4 deletions

File tree

apps/jira_utils/jira_information.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,16 +146,24 @@ def process_jira_command_line_config_file(
146146
version_string_not_targeted_jiras: str,
147147
target_versions: list[str],
148148
skip_projects: list[str],
149+
user: str,
150+
cloud: bool | None,
149151
) -> dict[str, Any]:
150152
# Process all the arguments passed from command line or config file or environment variable
151153
config_dict = get_util_config(util_name="pyutils-jira", config_file_path=config_file_path)
152154
url = url or config_dict.get("url", "")
153155
token = token or config_dict.get("token", "")
156+
user = user or config_dict.get("user", "")
157+
cloud = cloud if cloud is not None else config_dict.get("cloud", False)
154158

155159
if not (url and token):
156160
LOGGER.error("Jira url and token are required.")
157161
sys.exit(1)
158162

163+
if cloud and not user:
164+
LOGGER.error("Jira user is required for cloud Jira.")
165+
sys.exit(1)
166+
159167
return {
160168
"url": url,
161169
"token": token,
@@ -166,6 +174,8 @@ def process_jira_command_line_config_file(
166174
),
167175
"target_versions": target_versions or config_dict.get("target_versions", []),
168176
"skip_project_ids": skip_projects or config_dict.get("skip_project_ids", []),
177+
"user": user,
178+
"cloud": cloud,
169179
}
170180

171181

@@ -197,6 +207,12 @@ def process_jira_command_line_config_file(
197207
type=click.STRING,
198208
default=os.getenv("JIRA_TOKEN"),
199209
)
210+
@click.option(
211+
"--user",
212+
help="Provide the Jira user email (required for cloud Jira).",
213+
type=click.STRING,
214+
default=os.getenv("JIRA_USER"),
215+
)
200216
@click.option(
201217
"--issue-pattern",
202218
help="Provide the regex for Jira ids",
@@ -219,16 +235,24 @@ def process_jira_command_line_config_file(
219235
default="vfuture",
220236
)
221237
@click.option("--verbose", default=False, is_flag=True)
238+
@click.option(
239+
"--cloud",
240+
help="Use cloud Jira authentication (basic_auth with user email and API token).",
241+
is_flag=True,
242+
default=None,
243+
)
222244
def get_jira_mismatch(
223245
config_file_path: str,
224246
target_versions: list[str],
225247
url: str,
226248
token: str,
249+
user: str,
227250
skip_projects: list[str],
228251
resolved_statuses: list[str],
229252
issue_pattern: str,
230253
version_string_not_targeted_jiras: str,
231254
verbose: bool,
255+
cloud: bool | None,
232256
) -> None:
233257
LOGGER.setLevel(logging.DEBUG if verbose else logging.INFO)
234258
if not (config_file_path or (token and url)):
@@ -245,12 +269,20 @@ def get_jira_mismatch(
245269
skip_projects=skip_projects,
246270
version_string_not_targeted_jiras=version_string_not_targeted_jiras,
247271
target_versions=target_versions,
272+
user=user,
273+
cloud=cloud,
248274
)
249275

250-
jira_obj = JIRA(
251-
token_auth=jira_config_dict["token"],
252-
options={"server": jira_config_dict["url"]},
253-
)
276+
if jira_config_dict["cloud"]:
277+
jira_obj = JIRA(
278+
server=jira_config_dict["url"],
279+
basic_auth=(jira_config_dict["user"], jira_config_dict["token"]),
280+
)
281+
else:
282+
jira_obj = JIRA(
283+
token_auth=jira_config_dict["token"],
284+
options={"server": jira_config_dict["url"]},
285+
)
254286
jira_error: dict[str, str] = {}
255287

256288
if jira_id_dict := get_jiras_from_python_files(

config.example.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ pyutils-polarion-set-automated:
1212
pyutils-jira:
1313
url: <jira url>
1414
token: <jira token>
15+
user: <jira user email>
16+
cloud: false
1517
resolved_statuses:
1618
- verified
1719
- release pending

tests/jira_utils/test_jira_cfg_file.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
pyutils-jira:
22
url: "https://example.com"
33
token: ""
4+
user: "test@example.com"
5+
cloud: false
46
resolved_statuses:
57
- "RESOLVED"
68
issue_pattern: "*"

tests/jira_utils/test_jira_utils.py

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ def test_process_jira_command_line_config_file_valid_config(mocker):
4949
"version_string_not_targeted_jiras": version_string_not_targeted_jiras,
5050
"target_versions": target_versions,
5151
"skip_project_ids": skip_projects,
52+
"user": "",
53+
"cloud": False,
5254
},
5355
)
5456
result = process_jira_command_line_config_file(
@@ -60,6 +62,8 @@ def test_process_jira_command_line_config_file_valid_config(mocker):
6062
version_string_not_targeted_jiras,
6163
target_versions,
6264
skip_projects,
65+
user="",
66+
cloud=False,
6367
)
6468
assert result == {
6569
"url": url,
@@ -69,10 +73,195 @@ def test_process_jira_command_line_config_file_valid_config(mocker):
6973
"not_targeted_version_str": version_string_not_targeted_jiras,
7074
"target_versions": target_versions,
7175
"skip_project_ids": skip_projects,
76+
"user": "",
77+
"cloud": False,
7278
}
7379
mock_get_util_config.assert_called_once()
7480

7581

82+
def test_process_jira_command_line_config_file_cloud_valid(mocker):
83+
config_file_path = "/path/to/config"
84+
url = "https://example.com"
85+
token = "1234567890"
86+
user = "test@example.com"
87+
88+
mocker.patch(
89+
"apps.jira_utils.jira_information.get_util_config",
90+
return_value={
91+
"url": url,
92+
"token": token,
93+
"cloud": True,
94+
"user": user,
95+
},
96+
)
97+
result = process_jira_command_line_config_file(
98+
config_file_path=config_file_path,
99+
url=url,
100+
token=token,
101+
issue_pattern="*",
102+
resolved_statuses=["RESOLVED"],
103+
version_string_not_targeted_jiras="v1.*",
104+
target_versions=["v2"],
105+
skip_projects=[],
106+
user=user,
107+
cloud=True,
108+
)
109+
assert result["cloud"] is True
110+
assert result["user"] == user
111+
112+
113+
def test_process_jira_command_line_config_file_cloud_missing_user(mocker):
114+
config_file_path = "/path/to/config"
115+
url = "https://example.com"
116+
token = "1234567890"
117+
118+
mocker.patch(
119+
"apps.jira_utils.jira_information.get_util_config",
120+
return_value={
121+
"url": url,
122+
"token": token,
123+
},
124+
)
125+
with pytest.raises(SystemExit):
126+
process_jira_command_line_config_file(
127+
config_file_path=config_file_path,
128+
url=url,
129+
token=token,
130+
issue_pattern="*",
131+
resolved_statuses=["RESOLVED"],
132+
version_string_not_targeted_jiras="v1.*",
133+
target_versions=["v2"],
134+
skip_projects=[],
135+
user="",
136+
cloud=True,
137+
)
138+
139+
140+
def test_process_cloud_from_config_when_cli_not_passed(mocker):
141+
mocker.patch(
142+
"apps.jira_utils.jira_information.get_util_config",
143+
return_value={"url": "https://example.com", "token": "tok", "cloud": True, "user": "u@e.com"},
144+
)
145+
result = process_jira_command_line_config_file(
146+
config_file_path="/path",
147+
url="https://example.com",
148+
token="tok",
149+
issue_pattern="*",
150+
resolved_statuses=[],
151+
version_string_not_targeted_jiras="",
152+
target_versions=[],
153+
skip_projects=[],
154+
user="u@e.com",
155+
cloud=None,
156+
)
157+
assert result["cloud"] is True
158+
assert result["user"] == "u@e.com"
159+
160+
161+
def test_process_cloud_defaults_false_when_not_in_config(mocker):
162+
mocker.patch(
163+
"apps.jira_utils.jira_information.get_util_config",
164+
return_value={"url": "https://example.com", "token": "tok"},
165+
)
166+
result = process_jira_command_line_config_file(
167+
config_file_path="/path",
168+
url="https://example.com",
169+
token="tok",
170+
issue_pattern="*",
171+
resolved_statuses=[],
172+
version_string_not_targeted_jiras="",
173+
target_versions=[],
174+
skip_projects=[],
175+
user="",
176+
cloud=None,
177+
)
178+
assert result["cloud"] is False
179+
180+
181+
def test_process_cloud_from_config_missing_user_exits(mocker):
182+
mocker.patch(
183+
"apps.jira_utils.jira_information.get_util_config",
184+
return_value={"url": "https://example.com", "token": "tok", "cloud": True},
185+
)
186+
with pytest.raises(SystemExit):
187+
process_jira_command_line_config_file(
188+
config_file_path="/path",
189+
url="https://example.com",
190+
token="tok",
191+
issue_pattern="*",
192+
resolved_statuses=[],
193+
version_string_not_targeted_jiras="",
194+
target_versions=[],
195+
skip_projects=[],
196+
user="",
197+
cloud=None,
198+
)
199+
200+
201+
def test_process_user_from_config_fallback(mocker):
202+
mocker.patch(
203+
"apps.jira_utils.jira_information.get_util_config",
204+
return_value={"url": "https://example.com", "token": "tok", "user": "config@example.com", "cloud": True},
205+
)
206+
result = process_jira_command_line_config_file(
207+
config_file_path="/path",
208+
url="https://example.com",
209+
token="tok",
210+
issue_pattern="*",
211+
resolved_statuses=[],
212+
version_string_not_targeted_jiras="",
213+
target_versions=[],
214+
skip_projects=[],
215+
user="",
216+
cloud=True,
217+
)
218+
assert result["user"] == "config@example.com"
219+
assert result["cloud"] is True
220+
221+
222+
def test_process_cloud_false_explicit_ignores_config(mocker):
223+
mocker.patch(
224+
"apps.jira_utils.jira_information.get_util_config",
225+
return_value={"url": "https://example.com", "token": "tok", "cloud": True},
226+
)
227+
result = process_jira_command_line_config_file(
228+
config_file_path="/path",
229+
url="https://example.com",
230+
token="tok",
231+
issue_pattern="*",
232+
resolved_statuses=[],
233+
version_string_not_targeted_jiras="",
234+
target_versions=[],
235+
skip_projects=[],
236+
user="",
237+
cloud=False,
238+
)
239+
assert result["cloud"] is False
240+
241+
242+
def test_process_server_mode_default(mocker):
243+
mocker.patch(
244+
"apps.jira_utils.jira_information.get_util_config",
245+
return_value={"url": "https://example.com", "token": "tok"},
246+
)
247+
result = process_jira_command_line_config_file(
248+
config_file_path="/path",
249+
url="https://example.com",
250+
token="tok",
251+
issue_pattern="([A-Z]+-[0-9]+)",
252+
resolved_statuses=["resolved"],
253+
version_string_not_targeted_jiras="vfuture",
254+
target_versions=[],
255+
skip_projects=[],
256+
user="",
257+
cloud=False,
258+
)
259+
assert result["cloud"] is False
260+
assert result["user"] == ""
261+
assert result["url"] == "https://example.com"
262+
assert result["token"] == "tok"
263+
264+
76265
@pytest.mark.parametrize(
77266
"test_params",
78267
[

0 commit comments

Comments
 (0)