130130
131131import os
132132import re
133+ import threading
133134import time
134135import urllib .parse
135136from argparse import Namespace
@@ -418,6 +419,37 @@ def __init__(self, /,
418419 self .user : User
419420 super ().__init__ (option , name , parent , user , options = options )
420421
422+ def _get_user_aaid (self ) -> str :
423+ """
424+ Get the user's Atlassian Account ID (AAID) for Jira Cloud.
425+ """
426+ query = urllib .parse .quote (self .user .email )
427+ search_url = f"{ self .parent .url } /rest/api/3/user/search?query={ query } "
428+
429+ log .debug ("Fetching user AAID for %s from %s" , self .user .email , search_url )
430+
431+ try :
432+ response = self .parent .session .get (
433+ search_url ,
434+ timeout = self .parent .timeout
435+ )
436+ response .raise_for_status ()
437+ users = response .json ()
438+
439+ if not users :
440+ raise ReportError (
441+ f"No user found for email { self .user .email } in Jira Cloud"
442+ )
443+
444+ # Return the accountId of the first matching user
445+ return users [0 ]["accountId" ]
446+
447+ except requests .exceptions .RequestException as error :
448+ log .error ("Failed to fetch user AAID: %s" , error )
449+ raise ReportError (
450+ f"Failed to fetch user AAID for { self .user .email } "
451+ ) from error
452+
421453 def _get_user_identifier (self ) -> str :
422454 """
423455 Get the correct user identifier for JQL queries.
@@ -582,7 +614,11 @@ class JiraTransition(JiraStats):
582614
583615 def fetch (self ) -> None :
584616 self .parent : JiraStatsGroup
585- user_id = self ._get_user_identifier ()
617+ # For cloud we need AAID
618+ if self .parent .is_jira_cloud :
619+ user_id = self ._get_user_aaid ()
620+ else :
621+ user_id = self ._get_user_identifier ()
586622 log .info (
587623 "[%s] Searching for issues transitioned to '%s' by '%s'" ,
588624 self .option ,
@@ -591,7 +627,7 @@ def fetch(self) -> None:
591627 query = (
592628 f"status changed to '{ self .parent .transition_status } ' "
593629 f"and status changed by '{ user_id } ' "
594- f"after { self .options .since } before { self .options .until } "
630+ f"after ' { self .options .since } 00:00' before { self .options .until } "
595631 )
596632 if self .parent .project :
597633 query = query + f" AND project in ({ self .parent .project } )"
@@ -746,6 +782,7 @@ def __init__(self,
746782 user : Optional [User ] = None ) -> None :
747783 StatsGroup .__init__ (self , option , name , parent , user )
748784 self ._session : Optional [requests .Session ] = None
785+ self ._session_lock = threading .Lock ()
749786 # Make sure there is an url provided
750787 config = dict (Config ().section (option ))
751788 self .timeout : float = float (config .get ("timeout" , TIMEOUT ))
@@ -860,17 +897,17 @@ def __init__(self,
860897 option = f"{ option } -worklog" , parent = self ,
861898 name = f"Issues with worklogs in { option } " ))
862899
863- def _basic_auth_session (self ) -> requests .Response :
864- basic_auth = (self .auth_username , self .auth_password )
900+ def _basic_auth_session (self , _session ) -> requests .Response :
901+ _session . auth = (self .auth_username , self .auth_password )
865902
866903 if self .is_jira_cloud :
867904 # For Jira Cloud, verify credentials by calling
868905 # the /myself endpoint
869906 log .debug ("Connecting to Jira Cloud at %s for basic auth" , self .url )
870907 test_url = f"{ self .url } /rest/api/{ self .api_version } /myself"
871908 try :
872- response = self . session .get (
873- test_url , auth = basic_auth , verify = self .ssl_verify ,
909+ response = _session .get (
910+ test_url , verify = self .ssl_verify ,
874911 timeout = self .timeout )
875912 except (requests .exceptions .ConnectionError ,
876913 urllib3 .exceptions .NewConnectionError ,
@@ -882,14 +919,12 @@ def _basic_auth_session(self) -> requests.Response:
882919 "(not your password). Generate one at: "
883920 "https://id.atlassian.com/manage-profile/security/api-tokens"
884921 ) from error
885- # Store auth for all future requests
886- self .session .auth = basic_auth
887922 else :
888923 # For Jira Server/Data Center, use session-based auth
889924 log .debug ("Connecting to %s for basic auth" , self .auth_url )
890925 try :
891- response = self . session .get (
892- self .auth_url , auth = basic_auth , verify = self .ssl_verify ,
926+ response = _session .get (
927+ self .auth_url , verify = self .ssl_verify ,
893928 timeout = self .timeout )
894929 except (requests .exceptions .ConnectionError ,
895930 urllib3 .exceptions .NewConnectionError ,
@@ -900,13 +935,13 @@ def _basic_auth_session(self) -> requests.Response:
900935 ) from error
901936 return response
902937
903- def _token_auth_session (self ) -> requests .Response :
938+ def _token_auth_session (self , _session ) -> requests .Response :
904939 myself_url = f"{ self .url } /rest/api/{ self .api_version } /myself"
905940 log .debug ("Connecting to %s" , myself_url )
906- self . session .headers ["Authorization" ] = f"Bearer { self .token } "
941+ _session .headers ["Authorization" ] = f"Bearer { self .token } "
907942 while True :
908943 try :
909- response = self . session .get (
944+ response = _session .get (
910945 myself_url ,
911946 verify = self .ssl_verify ,
912947 timeout = self .timeout )
@@ -924,13 +959,11 @@ def _token_auth_session(self) -> requests.Response:
924959 break
925960 return response
926961
927- def _gss_api_auth_session (self ) -> requests .Response :
928- if self ._session is None :
929- raise RuntimeError ("Session has not been initialized" )
962+ def _gss_api_auth_session (self , _session ) -> requests .Response :
930963 log .debug ("Connecting to %s for gssapi auth" , self .auth_url )
931964 gssapi_auth = HTTPSPNEGOAuth (mutual_authentication = DISABLED )
932965 try :
933- response : requests .Response = self . _session .get (
966+ response : requests .Response = _session .get (
934967 self .auth_url , auth = gssapi_auth , verify = self .ssl_verify ,
935968 timeout = self .timeout )
936969 except (requests .exceptions .ConnectionError ,
@@ -943,72 +976,84 @@ def _gss_api_auth_session(self) -> requests.Response:
943976 return response
944977
945978 def renew_session (self ) -> requests .Session :
946- self ._session = None
979+ with self ._session_lock :
980+ self ._session = None
947981 return self .session
948982
949983 @property
950984 def session (self ) -> requests .Session :
951985 """ Initialize the session """
952986 # pylint: disable=too-many-branches
987+ # If session already exists, return it
953988 if self ._session is not None :
954989 return self ._session
955- self ._session = requests .Session ()
956- # Disable SSL warning when ssl_verify is False
957- if not self .ssl_verify :
958- urllib3 .disable_warnings (urllib3 .exceptions .InsecureRequestWarning )
959- while True :
960- if self .auth_type == 'basic' :
961- response = self ._basic_auth_session ()
962- elif self .auth_type == "token" :
963- response = self ._token_auth_session ()
964- else :
965- response = self ._gss_api_auth_session ()
966- if response .status_code == HTTPStatus .TOO_MANY_REQUESTS :
967- retry_after = 1
968- if response .headers .get ("X-RateLimit-Remaining" ) == "0" :
969- retry_after = max (int (response .headers ["retry-after" ]), 1 )
970- log .debug ("Jira rate limit exceeded." )
971- log .debug ("Sleeping now for %s." ,
972- listed (retry_after , 'second' ))
973- time .sleep (retry_after )
974- continue
975- try :
976- response .raise_for_status ()
977- except requests .exceptions .HTTPError as error :
978- log .error (error )
979- raise ReportError (
980- "Jira authentication failed. Check credentials or kinit."
981- ) from error
982- break
983- if self .token_expiration :
990+
991+ # Acquire lock to initialize session
992+ with self ._session_lock :
993+ # Double-check: another thread might have initialized it
994+ # while we were waiting for the lock
995+ if self ._session is not None :
996+ return self ._session
997+
998+ # Do not set it to self._sesson until it is fully ready
999+ _session = requests .Session ()
1000+ # Disable SSL warning when ssl_verify is False
1001+ if not self .ssl_verify :
1002+ urllib3 .disable_warnings (urllib3 .exceptions .InsecureRequestWarning )
9841003 while True :
1004+ if self .auth_type == 'basic' :
1005+ response = self ._basic_auth_session (_session )
1006+ elif self .auth_type == "token" :
1007+ response = self ._token_auth_session (_session )
1008+ else :
1009+ response = self ._gss_api_auth_session (_session )
1010+ if response .status_code == HTTPStatus .TOO_MANY_REQUESTS :
1011+ retry_after = 1
1012+ if response .headers .get ("X-RateLimit-Remaining" ) == "0" :
1013+ retry_after = max (int (response .headers ["retry-after" ]), 1 )
1014+ log .debug ("Jira rate limit exceeded." )
1015+ log .debug ("Sleeping now for %s." ,
1016+ listed (retry_after , 'second' ))
1017+ time .sleep (retry_after )
1018+ continue
9851019 try :
986- response = self ._session .get (
987- f"{ self .url } /rest/pat/latest/tokens" ,
988- verify = self .ssl_verify ,
989- timeout = self .timeout )
990-
9911020 response .raise_for_status ()
992- token_found = None
993- for token in response .json ():
994- if token ["name" ] == self .token_name :
995- token_found = token
996- break
997- if token_found is None :
998- raise ValueError (
999- f"Can't check validity for the '{ self .token_name } ' "
1000- f"token as it doesn't exist." )
1001- expiring_at = datetime .strptime (
1002- token_found ["expiringAt" ], r"%Y-%m-%dT%H:%M:%S.%f%z" )
1003- delta = (
1004- expiring_at .astimezone () - datetime .now ().astimezone ())
1005- if delta .days < self .token_expiration :
1006- log .warning ("Jira token '%s' expires in %s days." ,
1007- self .token_name , delta .days )
1008- except (requests .exceptions .HTTPError ,
1009- KeyError , ValueError , requests .Timeout ) as error :
1010- log .warning (error )
1011- time .sleep (1 )
1012- continue
1021+ except requests .exceptions .HTTPError as error :
1022+ log .error (error )
1023+ raise ReportError (
1024+ "Jira authentication failed. Check credentials or kinit."
1025+ ) from error
10131026 break
1014- return self ._session
1027+ if self .token_expiration :
1028+ while True :
1029+ try :
1030+ response = _session .get (
1031+ f"{ self .url } /rest/pat/latest/tokens" ,
1032+ verify = self .ssl_verify ,
1033+ timeout = self .timeout )
1034+
1035+ response .raise_for_status ()
1036+ token_found = None
1037+ for token in response .json ():
1038+ if token ["name" ] == self .token_name :
1039+ token_found = token
1040+ break
1041+ if token_found is None :
1042+ raise ValueError (
1043+ f"Can't check validity for the '{ self .token_name } ' "
1044+ f"token as it doesn't exist." )
1045+ expiring_at = datetime .strptime (
1046+ token_found ["expiringAt" ], r"%Y-%m-%dT%H:%M:%S.%f%z" )
1047+ delta = (
1048+ expiring_at .astimezone () - datetime .now ().astimezone ())
1049+ if delta .days < self .token_expiration :
1050+ log .warning ("Jira token '%s' expires in %s days." ,
1051+ self .token_name , delta .days )
1052+ except (requests .exceptions .HTTPError ,
1053+ KeyError , ValueError , requests .Timeout ) as error :
1054+ log .warning (error )
1055+ time .sleep (1 )
1056+ continue
1057+ break
1058+ self ._session = _session
1059+ return self ._session
0 commit comments