Skip to content

Commit 1b58a96

Browse files
committed
Jira fixes
1 parent 577a181 commit 1b58a96

File tree

1 file changed

+105
-21
lines changed

1 file changed

+105
-21
lines changed

examples/jira/python/jira_endpoints.py

Lines changed: 105 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,40 +5,50 @@
55
This is a simpler alternative to the plugin-based approach.
66
"""
77

8-
from typing import Dict, Any, List, Optional
8+
from typing import Dict, Any, List, Optional, Callable
99
import logging
1010
from atlassian import Jira
1111
from mxcp.runtime import config, db, on_init, on_shutdown
12+
import threading
13+
import functools
14+
import time
1215

1316
logger = logging.getLogger(__name__)
1417

1518
# Global JIRA client for reuse across all function calls
1619
jira_client: Optional[Jira] = None
20+
# Thread lock to protect client initialization
21+
_client_lock = threading.Lock()
1722

1823

1924
@on_init
2025
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.
2827
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
4031

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")
4252

4353

4454
@on_shutdown
@@ -51,13 +61,76 @@ def cleanup_jira_client():
5161
logger.info("JIRA client cleaned up")
5262

5363

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+
54126
def _get_jira_client() -> Jira:
55127
"""Get the global JIRA client."""
56128
if jira_client is None:
57129
raise RuntimeError("JIRA client not initialized. Make sure the server is started properly.")
58130
return jira_client
59131

60132

133+
@retry_on_session_expiration
61134
def jql_query(query: str, start: Optional[int] = 0, limit: Optional[int] = None) -> List[Dict[str, Any]]:
62135
"""Execute a JQL query against Jira.
63136
@@ -118,6 +191,7 @@ def _key(obj: Optional[Dict[str, Any]]) -> Optional[str]:
118191
return cleaned
119192

120193

194+
@retry_on_session_expiration
121195
def get_issue(issue_key: str) -> Dict[str, Any]:
122196
"""Get detailed information for a specific JIRA issue by its key.
123197
@@ -179,6 +253,7 @@ def _safe_get(obj, key, default=None):
179253
return cleaned_issue
180254

181255

256+
@retry_on_session_expiration
182257
def get_user(account_id: str) -> Dict[str, Any]:
183258
"""Get a specific user by their unique account ID.
184259
@@ -208,6 +283,7 @@ def get_user(account_id: str) -> Dict[str, Any]:
208283
}
209284

210285

286+
@retry_on_session_expiration
211287
def search_user(query: str) -> List[Dict[str, Any]]:
212288
"""Search for users by query string (username, email, or display name).
213289
@@ -241,6 +317,7 @@ def search_user(query: str) -> List[Dict[str, Any]]:
241317
return filtered_users
242318

243319

320+
@retry_on_session_expiration
244321
def list_projects() -> List[Dict[str, Any]]:
245322
"""Return a concise list of Jira projects.
246323
@@ -270,6 +347,7 @@ def safe_name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
270347
return concise
271348

272349

350+
@retry_on_session_expiration
273351
def get_project(project_key: str) -> Dict[str, Any]:
274352
"""Get details for a specific project by its key.
275353
@@ -324,6 +402,7 @@ def get_project(project_key: str) -> Dict[str, Any]:
324402
return cleaned_info
325403

326404

405+
@retry_on_session_expiration
327406
def get_project_roles(project_key: str) -> List[Dict[str, Any]]:
328407
"""Get all roles available in a project.
329408
@@ -367,6 +446,7 @@ def get_project_roles(project_key: str) -> List[Dict[str, Any]]:
367446
raise ValueError(f"Error retrieving project roles for '{project_key}': {e}") from e
368447

369448

449+
@retry_on_session_expiration
370450
def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]:
371451
"""Get users and groups for a specific role in a project.
372452
@@ -443,7 +523,11 @@ def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]:
443523
except Exception as e:
444524
# Handle various possible errors from the JIRA API
445525
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:
447531
raise ValueError(f"Project '{project_key}' not found in JIRA")
448532
elif "403" in error_msg or "forbidden" in error_msg:
449533
raise ValueError(f"Access denied to project '{project_key}' in JIRA")

0 commit comments

Comments
 (0)