5
5
This is a simpler alternative to the plugin-based approach.
6
6
"""
7
7
8
- from typing import Dict , Any , List , Optional
8
+ from typing import Dict , Any , List , Optional , Callable
9
9
import logging
10
10
from atlassian import Jira
11
11
from mxcp .runtime import config , db , on_init , on_shutdown
12
+ import threading
13
+ import functools
14
+ import time
12
15
13
16
logger = logging .getLogger (__name__ )
14
17
15
18
# Global JIRA client for reuse across all function calls
16
19
jira_client : Optional [Jira ] = None
20
+ # Thread lock to protect client initialization
21
+ _client_lock = threading .Lock ()
17
22
18
23
19
24
@on_init
20
25
def setup_jira_client ():
21
- """Initialize JIRA client when server starts."""
22
- global jira_client
23
- logger .info ("Initializing JIRA client..." )
24
-
25
- jira_config = config .get_secret ("jira" )
26
- if not jira_config :
27
- raise ValueError ("JIRA configuration not found. Please configure JIRA secrets in your user config." )
26
+ """Initialize JIRA client when server starts.
28
27
29
- required_keys = ["url" , "username" , "password" ]
30
- missing_keys = [key for key in required_keys if not jira_config .get (key )]
31
- if missing_keys :
32
- raise ValueError (f"Missing JIRA configuration keys: { ', ' .join (missing_keys )} " )
33
-
34
- jira_client = Jira (
35
- url = jira_config ["url" ],
36
- username = jira_config ["username" ],
37
- password = jira_config ["password" ],
38
- cloud = True
39
- )
28
+ Thread-safe: multiple threads can safely call this simultaneously.
29
+ """
30
+ global jira_client
40
31
41
- logger .info ("JIRA client initialized successfully" )
32
+ with _client_lock :
33
+ logger .info ("Initializing JIRA client..." )
34
+
35
+ jira_config = config .get_secret ("jira" )
36
+ if not jira_config :
37
+ raise ValueError ("JIRA configuration not found. Please configure JIRA secrets in your user config." )
38
+
39
+ required_keys = ["url" , "username" , "password" ]
40
+ missing_keys = [key for key in required_keys if not jira_config .get (key )]
41
+ if missing_keys :
42
+ raise ValueError (f"Missing JIRA configuration keys: { ', ' .join (missing_keys )} " )
43
+
44
+ jira_client = Jira (
45
+ url = jira_config ["url" ],
46
+ username = jira_config ["username" ],
47
+ password = jira_config ["password" ],
48
+ cloud = True
49
+ )
50
+
51
+ logger .info ("JIRA client initialized successfully" )
42
52
43
53
44
54
@on_shutdown
@@ -51,13 +61,76 @@ def cleanup_jira_client():
51
61
logger .info ("JIRA client cleaned up" )
52
62
53
63
64
+ def retry_on_session_expiration (func : Callable ) -> Callable :
65
+ """
66
+ Decorator that automatically retries functions on JIRA session expiration.
67
+
68
+ This only retries on HTTP 401 Unauthorized errors, not other authentication failures.
69
+ Retries up to 2 times on session expiration (3 total attempts).
70
+ Thread-safe: setup_jira_client() handles concurrent access internally.
71
+
72
+ Usage:
73
+ @retry_on_session_expiration
74
+ def my_jira_function():
75
+ # Function that might fail due to session expiration
76
+ pass
77
+ """
78
+ @functools .wraps (func )
79
+ def wrapper (* args , ** kwargs ):
80
+ max_retries = 2 # Hardcoded: 2 retries = 3 total attempts
81
+
82
+ for attempt in range (max_retries + 1 ):
83
+ try :
84
+ return func (* args , ** kwargs )
85
+ except Exception as e :
86
+ # Check if this is a 401 Unauthorized error (session expired)
87
+ if _is_session_expired (e ):
88
+ if attempt < max_retries :
89
+ logger .warning (f"Session expired on attempt { attempt + 1 } in { func .__name__ } : { e } " )
90
+ logger .info (f"Retrying after re-initializing client (attempt { attempt + 2 } /{ max_retries + 1 } )" )
91
+
92
+ try :
93
+ setup_jira_client () # Thread-safe internally
94
+ time .sleep (0.1 ) # Small delay to avoid immediate retry
95
+ except Exception as setup_error :
96
+ logger .error (f"Failed to re-initialize JIRA client: { setup_error } " )
97
+ raise setup_error # Raise the setup error, not the original session error
98
+ else :
99
+ # Last attempt failed, re-raise the session expiration error
100
+ raise e
101
+ else :
102
+ # Not a session expiration error, re-raise immediately
103
+ raise e
104
+
105
+ return wrapper
106
+
107
+
108
+ def _is_session_expired (exception : Exception ) -> bool :
109
+ """Check if the exception indicates a JIRA session has expired."""
110
+ error_msg = str (exception ).lower ()
111
+
112
+ # Check for HTTP 401 Unauthorized
113
+ if "401" in error_msg or "unauthorized" in error_msg :
114
+ return True
115
+
116
+ # Check for common session expiration messages
117
+ if any (phrase in error_msg for phrase in [
118
+ "session expired" , "session invalid" , "authentication failed" ,
119
+ "invalid session" , "session timeout"
120
+ ]):
121
+ return True
122
+
123
+ return False
124
+
125
+
54
126
def _get_jira_client () -> Jira :
55
127
"""Get the global JIRA client."""
56
128
if jira_client is None :
57
129
raise RuntimeError ("JIRA client not initialized. Make sure the server is started properly." )
58
130
return jira_client
59
131
60
132
133
+ @retry_on_session_expiration
61
134
def jql_query (query : str , start : Optional [int ] = 0 , limit : Optional [int ] = None ) -> List [Dict [str , Any ]]:
62
135
"""Execute a JQL query against Jira.
63
136
@@ -118,6 +191,7 @@ def _key(obj: Optional[Dict[str, Any]]) -> Optional[str]:
118
191
return cleaned
119
192
120
193
194
+ @retry_on_session_expiration
121
195
def get_issue (issue_key : str ) -> Dict [str , Any ]:
122
196
"""Get detailed information for a specific JIRA issue by its key.
123
197
@@ -179,6 +253,7 @@ def _safe_get(obj, key, default=None):
179
253
return cleaned_issue
180
254
181
255
256
+ @retry_on_session_expiration
182
257
def get_user (account_id : str ) -> Dict [str , Any ]:
183
258
"""Get a specific user by their unique account ID.
184
259
@@ -208,6 +283,7 @@ def get_user(account_id: str) -> Dict[str, Any]:
208
283
}
209
284
210
285
286
+ @retry_on_session_expiration
211
287
def search_user (query : str ) -> List [Dict [str , Any ]]:
212
288
"""Search for users by query string (username, email, or display name).
213
289
@@ -241,6 +317,7 @@ def search_user(query: str) -> List[Dict[str, Any]]:
241
317
return filtered_users
242
318
243
319
320
+ @retry_on_session_expiration
244
321
def list_projects () -> List [Dict [str , Any ]]:
245
322
"""Return a concise list of Jira projects.
246
323
@@ -270,6 +347,7 @@ def safe_name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
270
347
return concise
271
348
272
349
350
+ @retry_on_session_expiration
273
351
def get_project (project_key : str ) -> Dict [str , Any ]:
274
352
"""Get details for a specific project by its key.
275
353
@@ -324,6 +402,7 @@ def get_project(project_key: str) -> Dict[str, Any]:
324
402
return cleaned_info
325
403
326
404
405
+ @retry_on_session_expiration
327
406
def get_project_roles (project_key : str ) -> List [Dict [str , Any ]]:
328
407
"""Get all roles available in a project.
329
408
@@ -367,6 +446,7 @@ def get_project_roles(project_key: str) -> List[Dict[str, Any]]:
367
446
raise ValueError (f"Error retrieving project roles for '{ project_key } ': { e } " ) from e
368
447
369
448
449
+ @retry_on_session_expiration
370
450
def get_project_role_users (project_key : str , role_name : str ) -> Dict [str , Any ]:
371
451
"""Get users and groups for a specific role in a project.
372
452
@@ -443,7 +523,11 @@ def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]:
443
523
except Exception as e :
444
524
# Handle various possible errors from the JIRA API
445
525
error_msg = str (e ).lower ()
446
- if "404" in error_msg or "not found" in error_msg :
526
+
527
+ # Don't handle 401 errors here - let the retry decorator handle them
528
+ if "401" in error_msg or "unauthorized" in error_msg :
529
+ raise e # Let the retry decorator catch this
530
+ elif "404" in error_msg or "not found" in error_msg :
447
531
raise ValueError (f"Project '{ project_key } ' not found in JIRA" )
448
532
elif "403" in error_msg or "forbidden" in error_msg :
449
533
raise ValueError (f"Access denied to project '{ project_key } ' in JIRA" )
0 commit comments