Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:49:50 +08:00
commit adc4b2be25
147 changed files with 24716 additions and 0 deletions

View File

@@ -0,0 +1,569 @@
"""
JIRA Python Endpoints
This module provides direct Python MCP endpoints for querying Atlassian JIRA.
This is a simpler alternative to the plugin-based approach.
"""
from typing import Dict, Any, List, Optional, Callable
import logging
from atlassian import Jira
from mxcp.runtime import config, on_init, on_shutdown
import threading
import functools
import time
logger = logging.getLogger(__name__)
# Global JIRA client for reuse across all function calls
jira_client: Optional[Jira] = None
# Thread lock to protect client initialization
_client_lock = threading.Lock()
@on_init
def setup_jira_client() -> None:
"""Initialize JIRA client when server starts.
Thread-safe: multiple threads can safely call this simultaneously.
"""
global jira_client
with _client_lock:
logger.info("Initializing JIRA client...")
jira_config = config.get_secret("jira")
if not jira_config:
raise ValueError(
"JIRA configuration not found. Please configure JIRA secrets in your user config."
)
required_keys = ["url", "username", "password"]
missing_keys = [key for key in required_keys if not jira_config.get(key)]
if missing_keys:
raise ValueError(f"Missing JIRA configuration keys: {', '.join(missing_keys)}")
jira_client = Jira(
url=jira_config["url"],
username=jira_config["username"],
password=jira_config["password"],
cloud=True,
)
logger.info("JIRA client initialized successfully")
@on_shutdown
def cleanup_jira_client() -> None:
"""Clean up JIRA client when server stops."""
global jira_client
if jira_client:
# JIRA client doesn't need explicit cleanup, but we'll clear the reference
jira_client = None
logger.info("JIRA client cleaned up")
def retry_on_session_expiration(func: Callable[..., Any]) -> Callable[..., Any]:
"""
Decorator that automatically retries functions on JIRA session expiration.
This only retries on HTTP 401 Unauthorized errors, not other authentication failures.
Retries up to 2 times on session expiration (3 total attempts).
Thread-safe: setup_jira_client() handles concurrent access internally.
Usage:
@retry_on_session_expiration
def my_jira_function():
# Function that might fail due to session expiration
pass
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
max_retries = 2 # Hardcoded: 2 retries = 3 total attempts
for attempt in range(max_retries + 1):
try:
return func(*args, **kwargs)
except Exception as e:
# Check if this is a 401 Unauthorized error (session expired)
if _is_session_expired(e):
if attempt < max_retries:
logger.warning(
f"Session expired on attempt {attempt + 1} in {func.__name__}: {e}"
)
logger.info(
f"Retrying after re-initializing client (attempt {attempt + 2}/{max_retries + 1})"
)
try:
setup_jira_client() # Thread-safe internally
time.sleep(0.1) # Small delay to avoid immediate retry
except Exception as setup_error:
logger.error(f"Failed to re-initialize JIRA client: {setup_error}")
raise setup_error # Raise the setup error, not the original session error
else:
# Last attempt failed, re-raise the session expiration error
raise e
else:
# Not a session expiration error, re-raise immediately
raise e
return wrapper
def _is_session_expired(exception: Exception) -> bool:
"""Check if the exception indicates a JIRA session has expired."""
error_msg = str(exception).lower()
# Check for HTTP 401 Unauthorized
if "401" in error_msg or "unauthorized" in error_msg:
return True
# Check for common session expiration messages
if any(
phrase in error_msg
for phrase in [
"session expired",
"session invalid",
"authentication failed",
"invalid session",
"session timeout",
]
):
return True
return False
def _get_jira_client() -> Jira:
"""Get the global JIRA client."""
if jira_client is None:
raise RuntimeError("JIRA client not initialized. Make sure the server is started properly.")
return jira_client
@retry_on_session_expiration
def jql_query(
query: str, start: Optional[int] = None, limit: Optional[int] = None
) -> List[Dict[str, Any]]:
"""Execute a JQL query against Jira.
Args:
query: The JQL query string
start: Starting index for pagination (default: None, which becomes 0)
limit: Maximum number of results to return (default: None, meaning no limit)
Returns:
List of Jira issues matching the query
"""
logger.info("Executing JQL query: %s with start=%s, limit=%s", query, start, limit)
jira = _get_jira_client()
raw = jira.jql(
jql=query,
start=start if start is not None else 0,
limit=limit,
fields=(
"key,summary,status,resolution,resolutiondate,"
"assignee,reporter,issuetype,priority,"
"created,updated,labels,fixVersions,parent"
),
)
if not raw:
raise ValueError("JIRA JQL query returned empty result")
def _name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
"""Return obj['name'] if present, else None."""
return obj.get("name") if obj else None
def _key(obj: Optional[Dict[str, Any]]) -> Optional[str]:
return obj.get("key") if obj else None
cleaned: List[Dict[str, Any]] = []
jira_url = jira.url
for issue in raw.get("issues", []):
f = issue["fields"]
cleaned.append(
{
"key": issue["key"],
"summary": f.get("summary"),
"status": _name(f.get("status")),
"resolution": _name(f.get("resolution")),
"resolution_date": f.get("resolutiondate"),
"assignee": _name(f.get("assignee")),
"reporter": _name(f.get("reporter")),
"type": _name(f.get("issuetype")),
"priority": _name(f.get("priority")),
"created": f.get("created"),
"updated": f.get("updated"),
"labels": f.get("labels") or [],
"fix_versions": [_name(v) for v in f.get("fixVersions", [])],
"parent": _key(f.get("parent")),
"url": f"{jira_url}/browse/{issue['key']}", # web UI URL
}
)
return cleaned
@retry_on_session_expiration
def get_issue(issue_key: str) -> Dict[str, Any]:
"""Get detailed information for a specific JIRA issue by its key.
Args:
issue_key: The issue key (e.g., 'RD-123', 'TEST-456')
Returns:
Dictionary containing comprehensive issue information
Raises:
ValueError: If issue is not found or access is denied
"""
logger.info("Getting issue details for key: %s", issue_key)
jira = _get_jira_client()
# Get issue by key - this method handles the REST API call
issue = jira.issue(issue_key)
# Extract and clean up the most important fields for easier consumption
fields = issue.get("fields", {})
jira_url = jira.url
def _safe_get(obj: Any, key: str, default: Any = None) -> Any:
"""Safely get a value from a dict/object that might be None."""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
return getattr(obj, key, default)
cleaned_issue = {
"key": issue.get("key"),
"id": issue.get("id"),
"summary": fields.get("summary"),
"description": fields.get("description"),
"status": _safe_get(fields.get("status"), "name"),
"assignee": _safe_get(fields.get("assignee"), "displayName"),
"assignee_account_id": _safe_get(fields.get("assignee"), "accountId"),
"reporter": _safe_get(fields.get("reporter"), "displayName"),
"reporter_account_id": _safe_get(fields.get("reporter"), "accountId"),
"issue_type": _safe_get(fields.get("issuetype"), "name"),
"priority": _safe_get(fields.get("priority"), "name"),
"resolution": _safe_get(fields.get("resolution"), "name"),
"resolution_date": fields.get("resolutiondate"),
"created": fields.get("created"),
"updated": fields.get("updated"),
"due_date": fields.get("duedate"),
"labels": fields.get("labels", []) or [],
"components": (
[comp.get("name") for comp in fields.get("components", []) if comp and comp.get("name")]
if fields.get("components")
else []
),
"fix_versions": (
[ver.get("name") for ver in fields.get("fixVersions", []) if ver and ver.get("name")]
if fields.get("fixVersions")
else []
),
"project": {
"key": _safe_get(fields.get("project"), "key"),
"name": _safe_get(fields.get("project"), "name"),
},
"parent": _safe_get(fields.get("parent"), "key"),
"url": f"{jira_url}/browse/{issue.get('key')}",
}
return cleaned_issue
@retry_on_session_expiration
def get_user(account_id: str) -> Dict[str, Any]:
"""Get a specific user by their unique account ID.
Args:
account_id: The unique Atlassian account ID for the user.
Example: "557058:ab168c94-8485-405c-88e6-6458375eb30b"
Returns:
Dictionary containing filtered user details
Raises:
ValueError: If user is not found or account ID is invalid
"""
logger.info("Getting user details for account ID: %s", account_id)
jira = _get_jira_client()
# Get user by account ID - pass as account_id parameter for Jira Cloud
user = jira.user(account_id=account_id)
# Return only the requested fields
return {
"accountId": user.get("accountId"),
"displayName": user.get("displayName"),
"emailAddress": user.get("emailAddress"),
"active": user.get("active"),
"timeZone": user.get("timeZone"),
}
@retry_on_session_expiration
def search_user(query: str) -> List[Dict[str, Any]]:
"""Search for users by query string (username, email, or display name).
Args:
query: Search term - can be username, email, display name, or partial matches.
Examples: "ben@raw-labs.com", "Benjamin Gaidioz", "ben", "benjamin", "gaidioz"
Returns:
List of matching users with filtered fields. Empty list if no matches found.
"""
logger.info("Searching for users with query: %s", query)
jira = _get_jira_client()
# user_find_by_user_string returns a list of users matching the query
users = jira.user_find_by_user_string(query=query)
if not users:
return []
# Filter users to only include relevant fields
filtered_users = []
for user in users:
filtered_users.append(
{
"accountId": user.get("accountId"),
"displayName": user.get("displayName"),
"emailAddress": user.get("emailAddress"),
"active": user.get("active"),
"timeZone": user.get("timeZone"),
}
)
return filtered_users
@retry_on_session_expiration
def list_projects() -> List[Dict[str, Any]]:
"""Return a concise list of Jira projects.
Returns:
List of dictionaries containing project information
"""
logger.info("Listing all projects")
jira = _get_jira_client()
raw_projects: List[Dict[str, Any]] = jira.projects(expand="lead")
def safe_name(obj: Optional[Dict[str, Any]]) -> Optional[str]:
return obj.get("displayName") or obj.get("name") if obj else None
concise: List[Dict[str, Any]] = []
jira_url = jira.url
for p in raw_projects:
concise.append(
{
"key": p.get("key"),
"name": p.get("name"),
"type": p.get("projectTypeKey"), # e.g. software, business
"lead": safe_name(p.get("lead")),
"url": f"{jira_url}/projects/{p.get('key')}", # web UI URL
}
)
return concise
@retry_on_session_expiration
def get_project(project_key: str) -> Dict[str, Any]:
"""Get details for a specific project by its key.
Args:
project_key: The project key (e.g., 'TEST' for project TEST)
Returns:
Dictionary containing the project details
Raises:
ValueError: If project is not found or access is denied
"""
logger.info("Getting project details for key: %s", project_key)
jira = _get_jira_client()
try:
info = jira.project(project_key)
except Exception as e:
# Handle various possible errors from the JIRA API
error_msg = str(e).lower()
if "404" in error_msg or "not found" in error_msg:
raise ValueError(f"Project '{project_key}' not found in JIRA")
elif "403" in error_msg or "forbidden" in error_msg:
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
else:
# Re-raise other errors with context
raise ValueError(f"Error retrieving project '{project_key}': {e}") from e
# Filter to essential fields only to avoid response size issues
cleaned_info = {
"key": info.get("key"),
"name": info.get("name"),
"description": info.get("description"),
"projectTypeKey": info.get("projectTypeKey"),
"simplified": info.get("simplified"),
"style": info.get("style"),
"isPrivate": info.get("isPrivate"),
"archived": info.get("archived"),
}
# Add lead info if present
if "lead" in info and info["lead"]:
cleaned_info["lead"] = {
"displayName": info["lead"].get("displayName"),
"emailAddress": info["lead"].get("emailAddress"),
"accountId": info["lead"].get("accountId"),
"active": info["lead"].get("active"),
}
cleaned_info["url"] = f"{jira.url}/projects/{project_key}"
return cleaned_info
@retry_on_session_expiration
def get_project_roles(project_key: str) -> List[Dict[str, Any]]:
"""Get all roles available in a project.
Args:
project_key: The project key (e.g., 'TEST' for project TEST)
Returns:
List of roles available in the project
Raises:
ValueError: If project is not found or access is denied
"""
logger.info("Getting project roles for key: %s", project_key)
jira = _get_jira_client()
try:
# Get all project roles using the correct method
project_roles = jira.get_project_roles(project_key)
result = []
for role_name, role_url in project_roles.items():
# Extract role ID from URL (e.g., "https://domain.atlassian.net/rest/api/3/project/10000/role/10002")
role_id = role_url.split("/")[-1]
result.append({"name": role_name, "id": role_id})
return result
except Exception as e:
# Handle various possible errors from the JIRA API
error_msg = str(e).lower()
if "404" in error_msg or "not found" in error_msg:
raise ValueError(f"Project '{project_key}' not found in JIRA")
elif "403" in error_msg or "forbidden" in error_msg:
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
else:
# Re-raise other errors with context
raise ValueError(f"Error retrieving project roles for '{project_key}': {e}") from e
@retry_on_session_expiration
def get_project_role_users(project_key: str, role_name: str) -> Dict[str, Any]:
"""Get users and groups for a specific role in a project.
Args:
project_key: The project key (e.g., 'TEST' for project TEST)
role_name: The name of the role to get users for
Returns:
Dictionary containing users and groups for the specified role
Raises:
ValueError: If project or role is not found, or access is denied
"""
logger.info("Getting users for role '%s' in project '%s'", role_name, project_key)
jira = _get_jira_client()
try:
# First get all project roles to find the role ID
project_roles = jira.get_project_roles(project_key)
if role_name not in project_roles:
available_roles = list(project_roles.keys())
raise ValueError(
f"Role '{role_name}' not found in project '{project_key}'. Available roles: {available_roles}"
)
# Extract role ID from URL
role_url = project_roles[role_name]
role_id = role_url.split("/")[-1]
# Get role details including actors (users and groups)
role_details = jira.get_project_actors_for_role_project(project_key, role_id)
result = {
"project_key": project_key,
"role_name": role_name,
"role_id": role_id,
"users": [],
"groups": [],
}
# Process actors (role_details is a list of actors)
if isinstance(role_details, list):
for actor in role_details:
if isinstance(actor, dict):
actor_type = actor.get("type", "")
if actor_type == "atlassian-user-role-actor":
# Individual user
user_info = {
"accountId": actor.get("actorUser", {}).get("accountId"),
"displayName": actor.get("displayName"),
}
result["users"].append(user_info)
elif actor_type == "atlassian-group-role-actor":
# Group
group_info = {
"name": actor.get("displayName"),
"groupId": actor.get("actorGroup", {}).get("groupId"),
}
result["groups"].append(group_info)
else:
# Handle other actor types or simple user entries
display_name = actor.get("displayName") or actor.get("name")
if display_name:
user_info = {
"accountId": actor.get("accountId"),
"displayName": display_name,
}
result["users"].append(user_info)
return result
except ValueError:
# Re-raise ValueError as-is (these are our custom error messages)
raise
except Exception as e:
# Handle various possible errors from the JIRA API
error_msg = str(e).lower()
# Don't handle 401 errors here - let the retry decorator handle them
if "401" in error_msg or "unauthorized" in error_msg:
raise e # Let the retry decorator catch this
elif "404" in error_msg or "not found" in error_msg:
raise ValueError(f"Project '{project_key}' not found in JIRA")
elif "403" in error_msg or "forbidden" in error_msg:
raise ValueError(f"Access denied to project '{project_key}' in JIRA")
else:
# Re-raise other errors with context
raise ValueError(
f"Error retrieving users for role '{role_name}' in project '{project_key}': {e}"
) from e