Skip to content

Commit f06bbc8

Browse files
authored
Handle JIRA Auth changes with Atlassian Cloud Migration (#977)
* Test JIRA auth changes * Test JIRA auth changes * Test JIRA auth changes * Unittest changes
1 parent 1cc98ec commit f06bbc8

3 files changed

Lines changed: 112 additions & 26 deletions

File tree

cloud_governance/common/jira/jira.py

Lines changed: 80 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11

22
import asyncio
33
import logging
4+
from urllib.parse import quote
45

56
import aiohttp
67
import urllib3
@@ -31,26 +32,47 @@ def __init__(
3132
self.username = username
3233
self.ticket_queue = ticket_queue
3334
self.password = password
35+
self.token = token
36+
self._auth = None
3437
if not loop:
3538
self.loop = asyncio.new_event_loop()
3639
self.new_loop = True
3740
else:
3841
self.loop = loop
3942
self.new_loop = False
40-
self.token = token
41-
if not self.token:
42-
if self.password:
43-
payload = BasicAuth(self.username, self.password)
44-
else:
43+
44+
# Jira Cloud API tokens use HTTP Basic auth (email + token), same as curl -u user:token
45+
if self.token:
46+
if not self.username:
4547
logger.error(
46-
"Basic Authentication expected as no token was found but password is missing"
48+
"Basic Authentication with API token requires JIRA_USERNAME (email)"
4749
)
48-
raise JiraException
50+
raise JiraException(
51+
"Basic Authentication with API token requires JIRA_USERNAME (email)"
52+
)
53+
self._auth = BasicAuth(self.username, self.token)
54+
elif self.password:
55+
if not self.username:
56+
logger.error(
57+
"Basic Authentication requires JIRA_USERNAME"
58+
)
59+
raise JiraException(
60+
"Basic Authentication requires JIRA_USERNAME"
61+
)
62+
self._auth = BasicAuth(self.username, self.password)
4963
else:
50-
payload = "Bearer: %s" % self.token
51-
self.headers = {"Authorization": payload}
52-
except:
53-
pass
64+
logger.error(
65+
"Basic Authentication expected: set JIRA_TOKEN or JIRA_PASSWORD"
66+
)
67+
raise JiraException(
68+
"Basic Authentication expected: set JIRA_TOKEN or JIRA_PASSWORD"
69+
)
70+
self.headers = {"Accept": "application/json"}
71+
except JiraException:
72+
raise
73+
except Exception as ex:
74+
logger.error("Failed to initialize Jira client: %s", ex)
75+
raise JiraException from ex
5476

5577
def __exit__(self):
5678
if self.new_loop:
@@ -60,6 +82,7 @@ async def get_request(self, endpoint):
6082
logger.debug("GET: %s" % endpoint)
6183
try:
6284
async with aiohttp.ClientSession(
85+
auth=self._auth,
6386
headers=self.headers,
6487
loop=self.loop,
6588
) as session:
@@ -81,14 +104,19 @@ async def post_request(self, endpoint, payload):
81104
logger.debug("POST: {%s:%s}" % (endpoint, payload))
82105
try:
83106
async with aiohttp.ClientSession(
84-
headers=self.headers, loop=self.loop
107+
auth=self._auth,
108+
headers=self.headers,
109+
loop=self.loop,
85110
) as session:
86111
async with session.post(
87112
self.url + endpoint,
88113
json=payload,
89114
verify_ssl=False,
90115
) as response:
91-
data = await response.json(content_type="application/json")
116+
if response.status == 204:
117+
data = {}
118+
else:
119+
data = await response.json(content_type="application/json")
92120
except Exception as ex:
93121
logger.debug(ex)
94122
logger.error("There was something wrong with your request.")
@@ -98,13 +126,17 @@ async def post_request(self, endpoint, payload):
98126
return data
99127
if response.status == 404:
100128
logger.error("Resource not found: %s" % self.url + endpoint)
129+
else:
130+
logger.error(data)
101131
return False
102132

103133
async def put_request(self, endpoint, payload):
104134
logger.debug("POST: {%s:%s}" % (endpoint, payload))
105135
try:
106136
async with aiohttp.ClientSession(
107-
headers=self.headers, loop=self.loop
137+
auth=self._auth,
138+
headers=self.headers,
139+
loop=self.loop,
108140
) as session:
109141
async with session.put(
110142
self.url + endpoint,
@@ -158,11 +190,37 @@ async def create_subtask(self, parent_ticket, cloud, description, type_of_subtas
158190
response = await self.post_request(endpoint, data)
159191
return response
160192

193+
async def _resolve_watcher_to_account_id(self, watcher):
194+
"""
195+
Jira Cloud expects an Atlassian accountId in the watchers POST body (JSON string),
196+
not an email. Resolve email or short username via REST v2 user search.
197+
"""
198+
if not watcher or not str(watcher).strip():
199+
return None
200+
w = str(watcher).strip()
201+
user = await self.get_user_by_email(w)
202+
if user and user.get("accountId"):
203+
return user["accountId"]
204+
if "@" not in w:
205+
user = await self.get_user_by_email(f"{w}@redhat.com")
206+
if user and user.get("accountId"):
207+
return user["accountId"]
208+
logger.error("Could not resolve Jira accountId for watcher: %s", w)
209+
return None
210+
161211
async def add_watcher(self, ticket, watcher):
212+
account_id = await self._resolve_watcher_to_account_id(watcher)
213+
if not account_id:
214+
return False
162215
issue_id = "%s-%s" % (self.ticket_queue, ticket)
163216
endpoint = "/issue/%s/watchers" % issue_id
164-
logger.debug("POST transition: {%s:%s}" % (issue_id, watcher))
165-
response = await self.post_request(endpoint, watcher)
217+
logger.debug(
218+
"POST watcher issue=%s accountId=%s (from %s)",
219+
issue_id,
220+
account_id,
221+
watcher,
222+
)
223+
response = await self.post_request(endpoint, account_id)
166224
return response
167225

168226
async def add_label(self, ticket, label):
@@ -221,15 +279,18 @@ async def get_watchers(self, ticket):
221279
return result
222280

223281
async def get_user_by_email(self, email):
224-
endpoint = f"/user/search?username={email}"
282+
endpoint = f"/user/search?query={quote(email)}"
225283
logger.debug("GET user: %s" % endpoint)
226284
result = await self.get_request(endpoint)
227285
if not result:
228-
logger.error("User not found")
286+
logger.error("User not found for query: %s", email)
229287
return None
288+
el = email.lower()
230289
for user in result:
231-
if user.get("emailAddress") == email:
290+
if (user.get("emailAddress") or "").lower() == el:
232291
return user
292+
if len(result) == 1:
293+
return result[0]
233294
return None
234295

235296
async def get_pending_tickets(self):

cloud_governance/common/jira/jira_operations.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,18 @@ def __init__(self):
3131
self.__jira_url = self.__environment_variables_dict.get('JIRA_URL').strip()
3232
self.__jira_username = self.__environment_variables_dict.get('JIRA_USERNAME').strip()
3333
self.__jira_token = self.__environment_variables_dict.get('JIRA_TOKEN').strip()
34+
self.__jira_password = self.__environment_variables_dict.get('JIRA_PASSWORD', '').strip()
3435
self.__jira_queue = self.__environment_variables_dict.get('JIRA_QUEUE').strip()
3536
self.__cache_temp_dir = self.__environment_variables_dict.get('TEMPORARY_DIRECTORY', '').strip()
3637
self.__loop = asyncio.new_event_loop()
37-
self.__jira_object = Jira(url=self.__jira_url, username=self.__jira_username, token=self.__jira_token, ticket_queue=self.__jira_queue, loop=self.__loop)
38+
self.__jira_object = Jira(
39+
url=self.__jira_url,
40+
username=self.__jira_username,
41+
password=self.__jira_password or None,
42+
token=self.__jira_token or None,
43+
ticket_queue=self.__jira_queue,
44+
loop=self.__loop,
45+
)
3846

3947
@typeguard.typechecked
4048
@logger_time_stamp

tests/unittest/cloud_governance/cloud_resource_orchestration/mocks/mock_jira.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22
from functools import wraps
33
from unittest.mock import patch
44

5-
65
from cloud_governance.common.jira.jira_operations import JiraOperations
6+
from cloud_governance.main.environment_variables import environment_variables
7+
8+
# Jira() is still constructed with real credentials; CI often has no JIRA_* env vars.
9+
_JIRA_STANDIN_KEYS = ('JIRA_USERNAME', 'JIRA_URL', 'JIRA_QUEUE')
10+
_JIRA_STANDIN_DEFAULTS = {
11+
'JIRA_USERNAME': 'mock@example.com',
12+
'JIRA_URL': 'https://mock.atlassian.net',
13+
'JIRA_QUEUE': 'MOCK',
14+
}
715

816

917
def get_ticket_response():
@@ -94,10 +102,19 @@ def method_wrapper(*args, **kwargs):
94102
@param kwargs:
95103
@return:
96104
"""
97-
with patch.object(JiraOperations, 'get_issue', mock_get_issue),\
98-
patch.object(JiraOperations, 'move_issue_state', mock_move_issue_state), \
99-
patch.object(JiraOperations, 'get_all_issues', mock_get_all_issues):
100-
result = method(*args, **kwargs)
101-
return result
105+
env = environment_variables.environment_variables_dict
106+
saved_jira = {k: env.get(k, '') for k in _JIRA_STANDIN_KEYS}
107+
try:
108+
for key, default in _JIRA_STANDIN_DEFAULTS.items():
109+
if not (env.get(key) or '').strip():
110+
env[key] = default
111+
with patch.object(JiraOperations, 'get_issue', mock_get_issue),\
112+
patch.object(JiraOperations, 'move_issue_state', mock_move_issue_state), \
113+
patch.object(JiraOperations, 'get_all_issues', mock_get_all_issues):
114+
result = method(*args, **kwargs)
115+
return result
116+
finally:
117+
for key in _JIRA_STANDIN_KEYS:
118+
env[key] = saved_jira[key]
102119

103120
return method_wrapper

0 commit comments

Comments
 (0)