xrayclient.xray_client

   1import os
   2import re
   3import time
   4import json
   5import base64
   6import requests
   7import mimetypes
   8from jira import JIRA
   9from jsonpath_nz import log as logger, jprint
  10from typing import Optional, Dict, Any, List, Union, Tuple
  11
  12class JiraHandler():
  13    """A handler class for interacting with JIRA's REST API.
  14    This class provides methods to interact with JIRA's REST API, handling authentication,
  15    issue creation, retrieval, and various JIRA operations. It uses environment variables for
  16    configuration and provides robust error handling with comprehensive logging.
  17    The class supports creating issues with attachments, linking issues, and retrieving
  18    detailed issue information with customizable field selection. It handles various
  19    JIRA field types including user information, attachments, comments, and issue links.
  20    Attributes
  21    ----------
  22    client : JIRA
  23        The JIRA client instance used for API interactions
  24    Environment Variables
  25    --------------------
  26    JIRA_SERVER : str
  27        The JIRA server URL (default: 'https://arusa.atlassian.net')
  28    JIRA_USER : str
  29        The JIRA user email (default: 'yakub@arusatech.com')
  30    JIRA_API_KEY : str
  31        The JIRA API key for authentication (required)
  32    Methods
  33    -------
  34    create_issue(project_key, summary, description, **kwargs)
  35        Create a new JIRA issue with optional attachments, linking, and custom fields
  36    get_issue(issue_key, fields=None)
  37        Retrieve a JIRA issue with specified fields or all available fields
  38    Examples
  39    --------
  40    >>> handler = JiraHandler()
  41    >>> # Create a new issue
  42    >>> issue_key, issue_id = handler.create_issue(
  43    ...     project_key="PROJ",
  44    ...     summary="New feature implementation",
  45    ...     description="Implement new login flow",
  46    ...     issue_type="Story",
  47    ...     priority="High",
  48    ...     labels=["feature", "login"],
  49    ...     attachments=["/path/to/screenshot.png"]
  50    ... )
  51    >>> print(f"Created issue {issue_key} with ID {issue_id}")
  52    >>> # Retrieve issue details
  53    >>> issue = handler.get_issue("PROJ-123", fields=["summary", "status", "assignee"])
  54    >>> print(f"Issue: {issue['summary']} - Status: {issue['status']['name']}")
  55    Notes
  56    -----
  57    - Requires valid JIRA credentials stored in environment variables
  58    - Automatically loads configuration from .env file if present
  59    - Provides comprehensive error handling and logging
  60    - Supports various JIRA field types and custom fields
  61    - Handles file attachments with automatic MIME type detection
  62    - Creates issue links with configurable link types
  63    - Returns None for failed operations instead of raising exceptions
  64    """
  65    
  66    def __init__(self):
  67        """Initialize the JIRA client with configuration from environment variables.
  68        This constructor sets up the JIRA client by reading configuration from
  69        environment variables. It automatically loads variables from a .env file
  70        if present in the project root.
  71        Environment Variables
  72        -------------------
  73        JIRA_SERVER : str
  74            The JIRA server URL (default: 'https://arusatech.atlassian.net')
  75        JIRA_USER : str
  76            The JIRA user email (default: 'yakub@arusatech.com')
  77        JIRA_API_KEY : str
  78            The JIRA API key for authentication
  79        Raises
  80        ------
  81        Exception
  82            If the JIRA client initialization fails
  83        """
  84        try:
  85            # Load environment variables from .env file
  86            jira_server = os.getenv('JIRA_SERVER', 'https://arusatech.atlassian.net')
  87            jira_user = os.getenv('JIRA_USER', 'yakub@arusatech.com')
  88            jira_api_key = os.getenv('JIRA_API_KEY', "")
  89            # Validate required environment variables
  90            if not jira_api_key or jira_api_key == '<JIRA_API_KEY>':
  91                raise ValueError("JIRA_API_KEY environment variable is required and must be set to a valid API key")
  92            self.client = JIRA(
  93                server=jira_server,
  94                basic_auth=(jira_user, jira_api_key)
  95            )
  96            logger.info("JIRA client initialized successfully")
  97        except Exception as e:
  98            logger.error(f"Failed to initialize JIRA client: {str(e)}")
  99            logger.traceback(e)
 100            raise
 101
 102    def create_issue(
 103        self,
 104        project_key: str,
 105        summary: str,
 106        description: str,
 107        issue_type: str = None,
 108        priority: str = None,
 109        assignee: str = None,
 110        labels: List[str] = None,
 111        components: List[str] = None,
 112        attachments: List[str] = None,
 113        parent_issue_key: str = None,
 114        linked_issues: List[Dict[str, str]] = None,
 115        custom_fields: Dict[str, Any] = None
 116    ) -> Optional[tuple[str, str]]:
 117        """Create a new issue in JIRA with the specified details.
 118        This method creates a new JIRA issue with the provided details and handles
 119        optional features like attachments and issue linking.
 120        Parameters
 121        ----------
 122        project_key : str
 123            The key of the project where the issue should be created
 124        summary : str
 125            The summary/title of the issue
 126        description : str
 127            The detailed description of the issue
 128        issue_type : str, optional
 129            The type of issue (default: 'Bug')
 130        priority : str, optional
 131            The priority of the issue
 132        assignee : str, optional
 133            The username of the assignee
 134        labels : List[str], optional
 135            List of labels to add to the issue
 136        components : List[str], optional
 137            List of component names to add to the issue
 138        attachments : List[str], optional
 139            List of file paths to attach to the issue
 140        linked_issues : List[Dict[str, str]], optional
 141            List of issues to link to the new issue. Each dict should contain:
 142            - 'key': The issue key to link to
 143            - 'type': The type of link (default: 'Relates')
 144        custom_fields : Dict[str, Any], optional
 145            Dictionary of custom fields to set on the issue
 146        Returns
 147        -------
 148        Optional[tuple[str, str]]
 149            A tuple containing (issue_key, issue_id) if successful,
 150            (None, None) if creation fails
 151        Examples
 152        --------
 153        >>> handler = JiraHandler()
 154        >>> result = handler.create_issue(
 155        ...     project_key="PROJ",
 156        ...     summary="Bug in login",
 157        ...     description="User cannot login with valid credentials",
 158        ...     issue_type="Bug",
 159        ...     priority="High",
 160        ...     labels=["login", "bug"],
 161        ...     components=["Authentication"],
 162        ...     attachments=["/path/to/screenshot.png"],
 163        ...     linked_issues=[{"key": "PROJ-123", "type": "Blocks"}]
 164        ... )
 165        >>> print(f"Created issue {result[0]} with ID {result[1]}")
 166        """
 167        try:
 168            # Build basic issue fields
 169            issue_dict = {
 170                'project': {'key': project_key},
 171                'summary': summary,
 172                'description': description,
 173                'issuetype': {'name': issue_type or 'Bug'},
 174                'parent': {'key': parent_issue_key} if parent_issue_key else None
 175            }
 176            # Add optional fields
 177            if priority:
 178                issue_dict['priority'] = {'name': priority}
 179            if assignee:
 180                issue_dict['assignee'] = {'name': assignee}
 181            if labels:
 182                issue_dict['labels'] = labels
 183            if components:
 184                issue_dict['components'] = [{'name': c} for c in components]
 185            # Add any custom fields
 186            if custom_fields:
 187                issue_dict.update(custom_fields)
 188            # Create the issue
 189            issue = self.client.create_issue(fields=issue_dict)
 190            logger.info(f"Created JIRA issue : {issue.key} [ID: {issue.id}]")
 191            # Add attachments if provided
 192            if attachments:
 193                for file_path in attachments:
 194                    if os.path.exists(file_path):
 195                        self.client.add_attachment(
 196                            issue=issue.key,
 197                            attachment=file_path
 198                        )
 199                        logger.info(f"Added attachment: {file_path}")
 200                    else:
 201                        logger.warning(f"Attachment not found: {file_path}")
 202            # Create issue links if provided
 203            if linked_issues:
 204                for link in linked_issues:
 205                    try:
 206                        self.client.create_issue_link(
 207                            link.get('type', 'Relates'),
 208                            issue.key,
 209                            link['key']
 210                        )
 211                        logger.info(f"Created link between {issue.key} and {link['key']}")
 212                    except Exception as e:
 213                        logger.error(f"Failed to create link to {link['key']}: {str(e)}")
 214            return (issue.key, issue.id)
 215        except Exception as e:
 216            logger.error(f"Failed to create JIRA issue for project {project_key}: {str(e)}")
 217            logger.traceback(e)
 218            return (None, None)
 219
 220    def get_issue(self, issue_key: str, fields: List[str] = None) -> Optional[Dict[str, Any]]:
 221        """Get an issue by its key with specified fields.
 222        This method retrieves a JIRA issue using its key and returns the issue details
 223        with the specified fields. If no fields are specified, it returns all available fields.
 224        Parameters
 225        ----------
 226        issue_key : str
 227            The JIRA issue key to retrieve (e.g., "PROJ-123")
 228        fields : List[str], optional
 229            List of specific fields to retrieve. If None, all fields are returned.
 230            Common fields include: "summary", "description", "status", "assignee", 
 231            "reporter", "created", "updated", "priority", "labels", "components",
 232            "attachments", "comments", "issuetype", "project"
 233        Returns
 234        -------
 235        Optional[Dict[str, Any]]
 236            A dictionary containing the issue details if successful, None if the issue
 237            is not found or an error occurs.
 238        Examples
 239        --------
 240        >>> handler = JiraHandler()
 241        >>> # Get all fields
 242        >>> issue = handler.get_issue("PROJ-123")
 243        >>> print(f"Issue: {issue['summary']} - Status: {issue['status']['name']}")
 244        >>> # Get specific fields only
 245        >>> issue = handler.get_issue("PROJ-123", fields=["summary", "status", "assignee"])
 246        >>> print(f"Assignee: {issue['assignee']['displayName'] if issue['assignee'] else 'Unassigned'}")
 247        Notes
 248        -----
 249        - The issue key must be valid and accessible with current authentication
 250        - If fields parameter is None, all fields are returned
 251        - Some fields may be None if the issue doesn't have values for them
 252        - Failed operations are logged as errors with relevant details
 253        - The method handles missing issues gracefully by returning None
 254        """
 255        try:
 256            if not issue_key:
 257                logger.error("Issue key is required")
 258                return None
 259            # Define field mappings for JIRA API
 260            field_mappings = {
 261                'summary': 'summary',
 262                'description': 'description', 
 263                'status': 'status',
 264                'assignee': 'assignee',
 265                'reporter': 'reporter',
 266                'priority': 'priority',
 267                'labels': 'labels',
 268                'components': 'components',
 269                'issuetype': 'issuetype',
 270                'project': 'project',
 271                'created': 'created',
 272                'updated': 'updated',
 273                'resolutiondate': 'resolutiondate',
 274                'duedate': 'duedate',
 275                'attachments': 'attachments',
 276                'comment': 'comment',
 277                'issuelinks': 'issuelinks'
 278            }
 279            # Determine requested fields
 280            if fields is None:
 281                requested_fields = None
 282            else:
 283                # Map requested fields to JIRA field names
 284                jira_fields = [field_mappings.get(field, field) for field in fields]
 285                # Always include key and id as they're required
 286                if 'key' not in fields:
 287                    jira_fields.append('key')
 288                if 'id' not in fields:
 289                    jira_fields.append('id')
 290                requested_fields = ','.join(jira_fields)
 291            # Get the issue using the JIRA client
 292            issue = self.client.issue(issue_key, fields=requested_fields)
 293            if not issue:
 294                logger.warning(f"Issue not found: {issue_key}")
 295                return None
 296            # Helper function to safely get user attributes
 297            def get_user_dict(user_obj):
 298                if not user_obj:
 299                    return None
 300                try:
 301                    return {
 302                        'name': getattr(user_obj, 'name', None),
 303                        'displayName': getattr(user_obj, 'displayName', None),
 304                        'emailAddress': getattr(user_obj, 'emailAddress', None)
 305                    }
 306                except Exception:
 307                    return None
 308            # Helper function to safely get field value
 309            def safe_get_field(field_name, default=None):
 310                try:
 311                    return getattr(issue.fields, field_name, default)
 312                except AttributeError:
 313                    return default
 314            # Helper function to get object attributes safely
 315            def get_object_attrs(obj, attrs):
 316                if not obj:
 317                    return None
 318                return {attr: getattr(obj, attr, None) for attr in attrs}
 319            # Helper function to process attachments
 320            def process_attachments(attachments):
 321                if not attachments:
 322                    return []
 323                return [
 324                    {
 325                        'id': getattr(att, 'id', None),
 326                        'filename': getattr(att, 'filename', None),
 327                        'size': getattr(att, 'size', None),
 328                        'created': getattr(att, 'created', None),
 329                        'mimeType': getattr(att, 'mimeType', None)
 330                    } for att in attachments
 331                ]
 332            # Helper function to process comments
 333            def process_comments(comments):
 334                if not comments or not hasattr(comments, 'comments'):
 335                    return []
 336                return [
 337                    {
 338                        'id': getattr(comment, 'id', None),
 339                        'body': getattr(comment, 'body', None),
 340                        'author': get_user_dict(comment.author),
 341                        'created': getattr(comment, 'created', None),
 342                        'updated': getattr(comment, 'updated', None)
 343                    } for comment in comments.comments
 344                ]
 345            # Helper function to process issue links
 346            def process_issue_links(issue_links):
 347                if not issue_links:
 348                    return []
 349                def process_issue_reference(issue_ref, direction):
 350                    if not hasattr(issue_ref, direction) or not getattr(issue_ref, direction):
 351                        return None
 352                    ref_issue = getattr(issue_ref, direction)
 353                    return {
 354                        'key': getattr(ref_issue, 'key', None),
 355                        'id': getattr(ref_issue, 'id', None),
 356                        'fields': {
 357                            'summary': getattr(ref_issue.fields, 'summary', None),
 358                            'status': get_object_attrs(ref_issue.fields.status, ['name']) if ref_issue.fields.status else None
 359                        }
 360                    }
 361                return [
 362                    {
 363                        'id': getattr(link, 'id', None),
 364                        'type': get_object_attrs(link.type, ['id', 'name', 'inward', 'outward']) if link.type else None,
 365                        'inwardIssue': process_issue_reference(link, 'inwardIssue'),
 366                        'outwardIssue': process_issue_reference(link, 'outwardIssue')
 367                    } for link in issue_links
 368                ]
 369            # Build response dictionary
 370            issue_dict = {
 371                'key': issue.key,
 372                'id': issue.id
 373            }
 374            # Determine which fields to process
 375            fields_to_process = fields if fields is not None else list(field_mappings.keys())
 376            # Process each field
 377            for field in fields_to_process:
 378                if field in ['key', 'id']:
 379                    continue  # Already handled
 380                field_value = safe_get_field(field_mappings.get(field, field))
 381                match field:
 382                    case 'summary' | 'description' | 'created' | 'updated' | 'resolutiondate' | 'duedate':
 383                        issue_dict[field] = field_value
 384                    case 'status' | 'issuetype' | 'priority':
 385                        issue_dict[field] = get_object_attrs(field_value, ['id', 'name', 'description'])
 386                    case 'project':
 387                        issue_dict[field] = get_object_attrs(field_value, ['key', 'name', 'id'])
 388                    case 'assignee' | 'reporter':
 389                        issue_dict[field] = get_user_dict(field_value)
 390                    case 'labels':
 391                        issue_dict[field] = list(field_value) if field_value else []
 392                    case 'components':
 393                        issue_dict[field] = [
 394                            get_object_attrs(comp, ['id', 'name', 'description']) 
 395                            for comp in (field_value or [])
 396                        ]
 397                    case 'attachments':
 398                        issue_dict[field] = process_attachments(field_value)
 399                    case 'comments':
 400                        issue_dict[field] = process_comments(field_value)
 401                    case 'issuelinks':
 402                        issue_dict[field] = process_issue_links(field_value)
 403                    case _:
 404                        # Handle unknown fields or custom fields
 405                        issue_dict[field] = field_value
 406            # logger.info(f"Retrieved JIRA issue: {issue_key}")
 407            return issue_dict
 408        except Exception as e:
 409            logger.error(f"Failed to get JIRA issue {issue_key}: {str(e)}")
 410            logger.traceback(e)
 411            return None
 412
 413    def update_issue_summary(self, issue_key: str, new_summary: str) -> bool:
 414        """Update the summary of a JIRA issue.
 415        This method updates the summary field of an existing JIRA issue using the JIRA REST API.
 416        It validates the input parameters and handles errors gracefully with comprehensive logging.
 417        Parameters
 418        ----------
 419        issue_key : str
 420            The JIRA issue key to update (e.g., "PROJ-123")
 421        new_summary : str
 422            The new summary text to set for the issue
 423        Returns
 424        -------
 425        bool
 426            True if the summary was successfully updated, False if the operation failed.
 427            Returns None if an error occurs during the API request.
 428        Examples
 429        --------
 430        >>> handler = JiraHandler()
 431        >>> success = handler.update_issue_summary("PROJ-123", "Updated bug description")
 432        >>> print(success)
 433        True
 434        Notes
 435        -----
 436        - The issue key must be valid and accessible with current authentication
 437        - The new summary cannot be empty or None
 438        - Failed operations are logged as errors with relevant details
 439        - The method uses the JIRA client's update method for efficient API calls
 440        """
 441        try:
 442            # Validate input parameters
 443            if not issue_key or not issue_key.strip():
 444                logger.error("Issue key is required and cannot be empty")
 445                return False
 446            if not new_summary or not new_summary.strip():
 447                logger.error("New summary is required and cannot be empty")
 448                return False
 449            # Strip whitespace from inputs
 450            issue_key = issue_key.strip()
 451            new_summary = new_summary.strip()
 452            # logger.info(f"Updating summary for issue {issue_key}")
 453            # Get the issue object
 454            issue = self.client.issue(issue_key)
 455            if not issue:
 456                logger.error(f"Issue not found: {issue_key}")
 457                return False
 458            # Update the summary field
 459            issue.update(summary=new_summary)
 460            logger.info(f"Successfully updated summary for issue {issue_key}")
 461            return True
 462        except Exception as e:
 463            logger.error(f"Failed to update summary for issue {issue_key}: {str(e)}")
 464            logger.traceback(e)
 465            return False
 466
 467    def _build_auth_headers(self, api_key: str = None, user: str = None, cookie: str = None) -> Dict[str, str]:
 468        """
 469        Build authentication headers for JIRA API requests.
 470        
 471        This method converts an API key to base64 format and creates the proper
 472        Authorization header, similar to how Postman generates it.
 473        
 474        Parameters
 475        ----------
 476        api_key : str, optional
 477            The JIRA API key. If not provided, uses the one from environment variables.
 478        user : str, optional
 479            The JIRA user email. If not provided, uses the one from environment variables.
 480        cookie : str, optional
 481            Additional cookie value to include in headers.
 482            
 483        Returns
 484        -------
 485        Dict[str, str]
 486            Dictionary containing the Authorization and Cookie headers.
 487            
 488        Examples
 489        --------
 490        >>> handler = JiraHandler()
 491        >>> headers = handler._build_auth_headers()
 492        >>> print(headers)
 493        {
 494            'Authorization': 'Basic eWFrdWIubW9oYW1tYWRAd25jby5jb206QVRBVFQzeEZmR0YwN29tcFRCcU9FVUxlXzJjWlFDbkJXb2ZTYS1xMW92YmYxYnBURC1URmppY3VFczVBUzFJMkdjaXcybHlNMEFaRjl1T19OSU0yR0tIMlZ6SkQtQ0JtLTV2T05RNHhnMEFKbzVoaWhtQjIxaHc3Zk54MUFicjFtTWx1R0M4cVJoVDIzUkZlQUlaMVk3UUd0UnBLQlFLOV9iV0hyWnhPOWlucURRVjh4ZC0wd2tNPTIyQTdDMjg1',
 495            'Cookie': 'atlassian.xsrf.token=9dd7b0ae95b82b138b9fd93e27a45a6fd01c548e_lin'
 496        }
 497        """
 498        try:
 499            # Use provided values or fall back to environment variables
 500            api_key = api_key or os.getenv('JIRA_API_KEY')
 501            user = user or os.getenv('JIRA_USER', 'yakub@arusatech.com')
 502            
 503            if not api_key:
 504                raise ValueError("API key is required")
 505            if not user:
 506                raise ValueError("User email is required")
 507            
 508            # Create the credentials string in format "user:api_key"
 509            credentials = f"{user}:{api_key}"
 510            
 511            # Encode to base64
 512            encoded_credentials = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
 513            
 514            # Build headers
 515            headers = {
 516                'Authorization': f'Basic {encoded_credentials}'
 517            }
 518            
 519            # Add cookie if provided
 520            if cookie:
 521                headers['Cookie'] = cookie
 522                
 523            return headers
 524            
 525        except Exception as e:
 526            logger.error(f"Failed to build auth headers: {str(e)}")
 527            logger.traceback(e)
 528            raise
 529
 530    def make_jira_request(self, jira_key: str, url: str, method: str = "GET", payload: Dict = None, 
 531                         api_key: str = None, user: str = None, cookie: str = None) -> Optional[Dict]:
 532        """
 533        Make a JIRA API request with proper authentication headers.
 534        
 535        This method builds the authentication headers (similar to Postman) and
 536        makes the request to the JIRA API.
 537        
 538        Parameters
 539        ----------
 540        jira_key : str
 541            The JIRA issue key
 542        method : str, optional
 543            HTTP method (GET, POST, PUT, DELETE). Defaults to "GET"
 544        payload : Dict, optional
 545            Request payload for POST/PUT requests
 546        api_key : str, optional
 547            The JIRA API key. If not provided, uses environment variable
 548        user : str, optional
 549            The JIRA user email. If not provided, uses environment variable
 550        cookie : str, optional
 551            Additional cookie value
 552            
 553        Returns
 554        -------
 555        Optional[Dict]
 556            The JSON response from the API, or None if the request fails
 557            
 558        Examples
 559        --------
 560        >>> handler = JiraHandler()
 561        >>> response = handler.make_jira_request(
 562        ...     url="https://arusa.atlassian.net/rest/api/2/issue/XSP1-543213456"
 563        ... )
 564        >>> print(response)
 565        {'id': '12345', 'key': 'XSP1-3456', ...}
 566        """
 567        try:
 568            if not jira_key:
 569                logger.error("JIRA issue key is required")
 570                return None
 571            
 572            url = f"{os.getenv('JIRA_SERVER')}/rest/api/2/issue/{jira_key}"
 573            if not url:
 574                logger.error("JIRA API endpoint URL is required")
 575                return None
 576            # Build authentication headers
 577            headers = self._build_auth_headers(api_key, user, cookie)
 578            
 579            # Make the request
 580            response = requests.request(method, url, headers=headers, data=payload)
 581            response.raise_for_status()
 582            
 583            # Return JSON response
 584            return response.json()
 585            
 586        except requests.exceptions.RequestException as e:
 587            logger.error(f"JIRA API request failed: {str(e)}")
 588            logger.traceback(e)
 589            return None
 590        except Exception as e:
 591            logger.error(f"Unexpected error in JIRA request: {str(e)}")
 592            logger.traceback(e)
 593            return None
 594
 595    def download_jira_attachment_by_id(self, attachment_id: str, mime_type: str) -> Optional[Dict]:
 596        '''
 597        Download a JIRA attachment by its ID.
 598        '''
 599        try:
 600            # ATTACHMENT_URL = f"{os.getenv('JIRA_SERVER')}/rest/api/2/attachment/{attachment_id}"
 601            CONTENT_URL = f"{os.getenv('JIRA_SERVER')}/rest/api/2/attachment/content/{attachment_id}"
 602            if not CONTENT_URL:
 603                logger.error(f"No content URL found for attachment '{attachment_id}'")
 604                return None
 605            headers = self._build_auth_headers()
 606            download_response = requests.get(CONTENT_URL, headers=headers)
 607            download_response.raise_for_status()
 608            content = download_response.content
 609            #Process content based on type
 610            result = {
 611                'content': content,
 612                'mime_type': mime_type,
 613                'text_content': None,
 614                'json_content': None
 615            }
 616            
 617            # Handle text-based files
 618            if mime_type.startswith(('text/', 'application/json', 'application/xml' , 'json')):
 619                try:
 620                    text_content = content.decode('utf-8')
 621                    result['text_content'] = text_content
 622                    
 623                    # Try to parse as JSON
 624                    if mime_type == 'application/json':
 625                        try:
 626                            result['json_content'] = json.loads(text_content)
 627                        except json.JSONDecodeError:
 628                            pass
 629                except UnicodeDecodeError:
 630                    logger.error(f"Warning: Could not decode text content for {attachment_id}")
 631                    logger.traceback(e)
 632            
 633            return result
 634        except Exception as e:
 635            logger.error(f"Error downloading JIRA attachment: {str(e)}")
 636            logger.traceback(e)
 637            return None
 638        
 639
 640class XrayGraphQL(JiraHandler):
 641    """A comprehensive client for interacting with Xray Cloud's GraphQL API.
 642    This class extends JiraHandler to provide specialized methods for interacting with
 643    Xray Cloud's GraphQL API for test management. It handles authentication, test plans,
 644    test executions, test runs, defects, evidence, and other Xray-related operations 
 645    through GraphQL queries and mutations.
 646    Inherits
 647    --------
 648    JiraHandler
 649        Base class providing JIRA client functionality and issue management
 650    Attributes
 651    ----------
 652    client_id : str
 653        The client ID for Xray authentication
 654    client_secret : str
 655        The client secret for Xray authentication
 656    xray_base_url : str
 657        Base URL for Xray Cloud API (defaults to 'https://us.xray.cloud.getxray.app')
 658    logger : Logger
 659        Logger instance for debugging and error tracking
 660    token : str
 661        Authentication token obtained from Xray
 662    Methods
 663    -------
 664    Authentication & Setup
 665    ---------------------
 666    __init__()
 667        Initialize XrayGraphQL client with authentication and configuration settings.
 668    _get_auth_token() -> Optional[str]
 669        Authenticate with Xray Cloud API and obtain an authentication token.
 670    _make_graphql_request(query: str, variables: Dict) -> Optional[Dict]
 671        Makes a GraphQL request to the Xray API with proper authentication.
 672    _parse_table(table_str: str) -> Dict[str, Union[List[int], List[float]]]
 673        Parse a string representation of a table into a dictionary of numeric values.
 674    Issue ID Management
 675    ------------------
 676    get_issue_id_from_jira_id(issue_key: str, issue_type: str) -> Optional[str]
 677        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
 678    Test Plan Operations
 679    -------------------
 680    get_tests_from_test_plan(test_plan: str) -> Optional[Dict[str, str]]
 681        Retrieves all tests associated with a given test plan.
 682    get_test_plan_data(test_plan: str) -> Optional[Dict[str, Union[List[int], List[float]]]]
 683        Retrieves and parses tabular data from a test plan's description.
 684    Test Set Operations
 685    ------------------
 686    get_tests_from_test_set(test_set: str) -> Optional[Dict[str, str]]
 687        Retrieves all tests associated with a given test set.
 688    filter_test_set_by_test_case(test_key: str) -> Optional[Dict[str, str]]
 689        Retrieves all test sets containing a specific test case.
 690    filter_tags_by_test_case(test_key: str) -> Optional[List[str]]
 691        Extracts and filters tags from test sets associated with a test case.
 692    Test Execution Operations
 693    ------------------------
 694    get_tests_from_test_execution(test_execution: str) -> Optional[Dict[str, str]]
 695        Retrieves all tests associated with a given test execution.
 696    get_test_execution(test_execution: str) -> Optional[Dict]
 697        Retrieve detailed information about a test execution from Xray.
 698    create_test_execution(test_issue_keys: List[str], project_key: Optional[str], 
 699                         summary: Optional[str], description: Optional[str]) -> Optional[Dict]
 700        Creates a new test execution with specified test cases.
 701    create_test_execution_from_test_plan(test_plan: str) -> Optional[Dict[str, Dict[str, str]]]
 702        Creates a test execution from a given test plan with all associated tests.
 703    add_test_execution_to_test_plan(test_plan: str, test_execution: str) -> Optional[Dict]
 704        Add a test execution to an existing test plan in Xray.
 705    Test Run Operations
 706    ------------------
 707    get_test_runstatus(test_case: str, test_execution: str) -> Optional[Tuple[str, str]]
 708        Retrieves the status of a test run for a specific test case.
 709    get_test_run_by_id(test_case_id: str, test_execution_id: str) -> Optional[Tuple[str, str]]
 710        Retrieves the test run ID and status using internal Xray IDs.
 711    update_test_run_status(test_run_id: str, test_run_status: str) -> bool
 712        Updates the status of a specific test run.
 713    update_test_run_comment(test_run_id: str, test_run_comment: str) -> bool
 714        Updates the comment of a specific test run.
 715    get_test_run_comment(test_run_id: str) -> Optional[str]
 716        Retrieve the comment of a specific test run from Xray.
 717    append_test_run_comment(test_run_id: str, test_run_comment: str) -> bool
 718        Append a comment to an existing test run comment.
 719    Evidence & Defect Management
 720    ---------------------------
 721    add_evidence_to_test_run(test_run_id: str, evidence_path: str) -> bool
 722        Add evidence (attachments) to a test run in Xray.
 723    create_defect_from_test_run(test_run_id: str, project_key: str, parent_issue_key: str,
 724                               defect_summary: str, defect_description: str) -> Optional[Dict]
 725        Create a defect from a test run and link it to the test run in Xray.
 726    Examples
 727    --------
 728    >>> client = XrayGraphQL()
 729    >>> test_plan_tests = client.get_tests_from_test_plan("TEST-123")
 730    >>> print(test_plan_tests)
 731    {'TEST-124': '10001', 'TEST-125': '10002'}
 732    >>> test_execution = client.create_test_execution_from_test_plan("TEST-123")
 733    >>> print(test_execution)
 734    {
 735        'TEST-124': {
 736            'test_run_id': '5f7c3',
 737            'test_execution_key': 'TEST-456',
 738            'test_plan_key': 'TEST-123'
 739        }
 740    }
 741    >>> # Update test run status
 742    >>> success = client.update_test_run_status("test_run_id", "PASS")
 743    >>> print(success)
 744    True
 745    >>> # Add evidence to test run
 746    >>> evidence_added = client.add_evidence_to_test_run("test_run_id", "/path/to/screenshot.png")
 747    >>> print(evidence_added)
 748    True
 749    Notes
 750    -----
 751    - Requires valid Xray Cloud credentials (client_id and client_secret)
 752    - Uses GraphQL for all API interactions
 753    - Implements automatic token refresh
 754    - Handles rate limiting and retries
 755    - All methods include comprehensive error handling and logging
 756    - Returns None for failed operations instead of raising exceptions
 757    - Supports various file types for evidence attachments
 758    - Integrates with JIRA for defect creation and issue management
 759    - Provides both synchronous and asynchronous operation patterns
 760    - Includes retry logic for transient failures
 761    """
 762    
 763    def __init__(self):
 764        """Initialize XrayGraphQL client with authentication and configuration settings.    
 765        This constructor sets up the XrayGraphQL client by:
 766        1. Loading environment variables from .env file
 767        2. Reading required environment variables for authentication
 768        3. Configuring the base URL for Xray Cloud
 769        4. Obtaining an authentication token
 770        Required Environment Variables
 771        ----------------------------
 772        XRAY_CLIENT_ID : str
 773            Client ID for Xray authentication
 774        XRAY_CLIENT_SECRET : str
 775            Client secret for Xray authentication
 776        XRAY_BASE_URL : str, optional
 777            Base URL for Xray Cloud API. Defaults to 'https://us.xray.cloud.getxray.app'
 778        Attributes
 779        ----------
 780        client_id : str
 781            Xray client ID from environment
 782        client_secret : str
 783            Xray client secret from environment
 784        xray_base_url : str
 785            Base URL for Xray Cloud API
 786        logger : Logger
 787            Logger instance for debugging and error tracking
 788        token : str
 789            Authentication token obtained from Xray
 790        Raises
 791        ------
 792        Exception
 793            If authentication fails or required environment variables are missing
 794        """
 795        super().__init__()
 796        try:
 797            # Load environment variables from .env file
 798            self.client_id = os.getenv('XRAY_CLIENT_ID')
 799            self.client_secret = os.getenv('XRAY_CLIENT_SECRET')
 800            self.xray_base_url = os.getenv('XRAY_BASE_URL', 'https://us.xray.cloud.getxray.app')
 801            self.logger = logger
 802            # Validate required environment variables
 803            if not self.client_id or self.client_id == '<CLIENT_ID>':
 804                raise ValueError("XRAY_CLIENT_ID environment variable is required")
 805            if not self.client_secret or self.client_secret == '<CLIENT_SECRET>':
 806                raise ValueError("XRAY_CLIENT_SECRET environment variable is required")
 807            # Get authentication token
 808            self.token = self._get_auth_token()
 809            if not self.token:
 810                logger.error("Failed to authenticate with Xray GraphQL")
 811                raise Exception("Failed to initialize XrayGraphQL: No authentication token")
 812        except Exception as e:
 813            logger.error(f"Error initializing XrayGraphQL: {e}")
 814            logger.traceback(e)
 815            raise e
 816    
 817    def _get_auth_token(self) -> Optional[str]:
 818        """Authenticate with Xray Cloud API and obtain an authentication token.
 819        Makes a POST request to the Xray authentication endpoint using the client credentials 
 820        to obtain a JWT token for subsequent API calls.
 821        Returns
 822        -------
 823        Optional[str]
 824            The authentication token if successful, None if authentication fails.
 825            The token is stripped of surrounding quotes before being returned.
 826        Raises
 827        ------
 828        requests.exceptions.RequestException
 829            If the HTTP request fails
 830        requests.exceptions.HTTPError
 831            If the server returns an error status code
 832        Notes
 833        -----
 834        - The token is obtained from the Xray Cloud API endpoint /api/v2/authenticate
 835        - The method uses client credentials stored in self.client_id and self.client_secret
 836        - Failed authentication attempts are logged as errors
 837        - Successful authentication is logged at debug level
 838        """
 839        try:
 840            auth_url = f"{self.xray_base_url}/api/v2/authenticate"
 841            auth_headers = {"Content-Type": "application/json"}
 842            auth_payload = {
 843                "client_id": self.client_id,
 844                "client_secret": self.client_secret
 845            }
 846            # logger.debug("Attempting Xray authentication", "auth_start")
 847            logger.debug("Attempting Xray authentication... auth start")
 848            response = requests.post(auth_url, headers=auth_headers, json=auth_payload)
 849            response.raise_for_status()
 850            token = response.text.strip('"')
 851            # logger.info("Successfully authenticated with Xray", "auth_success")
 852            logger.debug("Successfully authenticated with Xray... auth success again")
 853            return token
 854        except Exception as e:
 855            # logger.error("Xray authentication failed", "auth_failed", error=e)
 856            logger.error("Xray authentication failed. auth failed")
 857            logger.traceback(e)
 858            return None
 859    
 860    def _parse_table(self, table_str: str) -> Dict[str, Union[List[int], List[float]]]:
 861        """Parse a string representation of a table into a dictionary of numeric values.
 862        Parameters
 863        ----------
 864        table_str : str
 865            A string containing the table data in markdown-like format.
 866            Example format::
 867                header1 || header2 || header3
 868                |row1   |value1    |[1, 2, 3]|
 869                |row2   |value2    |42       |
 870        Returns
 871        -------
 872        Dict[str, Union[List[int], List[float]]]
 873            A dictionary where:
 874            * Keys are strings derived from the first column (lowercase, underscores)
 875            * Values are either:
 876                * Lists of integers/floats (for array-like values in brackets)
 877                * Lists of individual numbers (for single numeric values)
 878            For non-array columns, duplicate values are removed and sorted.
 879            Returns None if parsing fails.
 880        Examples
 881        --------
 882        >>> table_str = '''header1 || header2
 883        ...                |temp   |[1, 2, 3]|
 884        ...                |value  |42       |'''
 885        >>> result = client._parse_table(table_str)
 886        >>> print(result)
 887        {
 888            'temp': [1, 2, 3],
 889            'value': [42]
 890        }
 891        """
 892        try:
 893            # Split the table into lines
 894            lines = table_str.strip().split('\n')
 895            # Process each data row
 896            result = {}
 897            for line in lines[1:]:
 898                if not line.startswith('|'):
 899                    continue
 900                # Split the row into columns
 901                columns = [col.strip() for col in line.split('|')[1:-1]]
 902                if not columns:
 903                    continue
 904                key = columns[0].replace(' ', '_').lower()
 905                values = []
 906                # Process each value column
 907                for col in columns[1:]:
 908                    # Handle list values
 909                    if col.startswith('[') and col.endswith(']'):
 910                        try:
 911                            # Clean and parse the list
 912                            list_str = col[1:-1].replace(',', ' ')
 913                            list_items = [item.strip() for item in list_str.split() if item.strip()]
 914                            num_list = [float(item) if '.' in item else int(item) for item in list_items]
 915                            values.append(num_list)
 916                        except (ValueError, SyntaxError):
 917                            pass
 918                    elif col.strip():  # Handle simple numeric values
 919                        try:
 920                            num = float(col) if '.' in col else int(col)
 921                            values.append(num)
 922                        except ValueError:
 923                            pass
 924                # Store in result
 925                if key:
 926                    if key in result:
 927                        result[key].extend(values)
 928                    else:
 929                        result[key] = values
 930            # For temperature (simple values), remove duplicates and sort
 931            for key in result:
 932                if all(not isinstance(v, list) for v in result[key]):
 933                    result[key] = sorted(list(set(result[key])))
 934            return result
 935        except Exception as e:
 936            logger.error(f"Error parsing table: {e}")
 937            logger.traceback(e)
 938            return None
 939    
 940    def _make_graphql_request(self, query: str, variables: Dict) -> Optional[Dict]:
 941        """
 942        Makes a GraphQL request to the Xray API with the provided query and variables.
 943        This internal method handles the execution of GraphQL queries against the Xray API,
 944        including proper authentication and error handling.
 945        Args:
 946            query (str): The GraphQL query or mutation to execute
 947            variables (Dict): Variables to be used in the GraphQL query/mutation
 948        Returns:
 949            Optional[Dict]: The 'data' field from the GraphQL response if successful,
 950                           None if the request fails or contains GraphQL errors
 951        Raises:
 952            No exceptions are raised - all errors are caught, logged, and return None
 953        Example:
 954            query = '''
 955                query GetTestPlan($id: String!) {
 956                    getTestPlan(issueId: $id) {
 957                        issueId
 958                    }
 959                }
 960            '''
 961            variables = {"id": "12345"}
 962            result = self._make_graphql_request(query, variables)
 963        Note:
 964            - Automatically includes authentication token in request headers
 965            - Logs errors if the request fails or if GraphQL errors are present
 966            - Returns None instead of raising exceptions to allow for graceful error handling
 967            - Only returns the 'data' portion of the GraphQL response
 968        """
 969        try:
 970            graphql_url = f"{self.xray_base_url}/api/v2/graphql"
 971            headers = {
 972                "Authorization": f"Bearer {self.token}",
 973                "Content-Type": "application/json"
 974            }
 975            payload = {"query": query, "variables": variables}
 976            # logger.debug(f'Making GraphQL request "query": {query}, "variables": {variables} ')
 977            response = requests.post(graphql_url, headers=headers, json=payload)
 978            jprint(response.json())
 979
 980            response.raise_for_status()
 981            try:
 982                data = response.json()
 983                jprint(data)
 984            except:
 985                data = response.text
 986                logger.debug(f"Response text: {data}")
 987            if 'errors' in data:
 988                logger.error(f'GraphQL request failed: {data["errors"]}')
 989                return None
 990            return data['data']
 991        except Exception as e:
 992            logger.error(f"GraphQL request failed due to {e} ")
 993            logger.traceback(e)
 994            return None
 995    
 996    def get_issue_id_from_jira_id(self, issue_key: str, issue_type: str) -> Optional[str]:
 997        """
 998        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
 999        This method queries the Xray GraphQL API to find the internal issue ID corresponding
1000        to a JIRA issue key. It supports different types of Xray artifacts including test plans,
1001        test executions, test sets, and tests.
1002        Args:
1003            issue_key (str): The JIRA issue key (e.g., "PROJECT-123")
1004            issue_type (str): The type of Xray artifact. Supported values are:
1005                - "plan" or contains "plan": For Test Plans
1006                - "exec" or contains "exec": For Test Executions
1007                - "set" or contains "set": For Test Sets
1008                - "test" or contains "test": For Tests
1009                If not provided, defaults to "plan"
1010        Returns:
1011            Optional[str]: The internal Xray issue ID if found, None if:
1012                - The issue key doesn't exist
1013                - The GraphQL request fails
1014                - Any other error occurs during processing
1015        Examples:
1016            >>> client.get_issue_id_from_jira_id("TEST-123", "plan")
1017            '10000'
1018            >>> client.get_issue_id_from_jira_id("TEST-456", "test")
1019            '10001'
1020            >>> client.get_issue_id_from_jira_id("INVALID-789", "plan")
1021            None
1022        Note:
1023            The method performs a case-insensitive comparison when matching issue keys.
1024            The project key is extracted from the issue_key (text before the hyphen)
1025            to filter results by project.
1026        """
1027        try:
1028            parse_project = issue_key.split("-")[0]
1029            function_name = "getTestPlans"
1030            if not issue_type:
1031                issue_type = "plan"
1032            if "plan" in issue_type.lower():
1033                function_name = "getTestPlans"
1034                query = """
1035                    query GetIds($limit: Int!, $jql: String!) {    
1036                        getTestPlans(limit: $limit, jql:$jql) {
1037                            results {
1038                                issueId
1039                                jira(fields: ["key"])
1040                            }
1041                        }
1042                    }
1043                    """
1044            if "exec" in issue_type.lower():
1045                function_name = "getTestExecutions"
1046                query = """
1047                    query GetIds($limit: Int!, $jql: String!) {    
1048                        getTestExecutions(limit: $limit, jql:$jql) {
1049                            results {
1050                                issueId
1051                                jira(fields: ["key"])
1052                            }
1053                        }
1054                    }
1055                    """
1056            if "set" in issue_type.lower():
1057                function_name = "getTestSets"
1058                query = """
1059                    query GetIds($limit: Int!, $jql: String!) {    
1060                        getTestSets(limit: $limit, jql:$jql) {
1061                            results {
1062                                issueId
1063                                jira(fields: ["key"])
1064                            }
1065                        }
1066                    }
1067                    """
1068            if "test" in issue_type.lower():
1069                function_name = "getTests"
1070                query = """
1071                    query GetIds($limit: Int!, $jql: String!) {    
1072                        getTests(limit: $limit, jql:$jql) {
1073                            results {
1074                                issueId
1075                                jira(fields: ["key"])
1076                            }
1077                        }
1078                    }
1079                    """
1080            variables = {
1081                "limit": 10,
1082                "jql":  f"project = '{parse_project}' AND key = '{issue_key}'"
1083            }
1084            data = self._make_graphql_request(query, variables)
1085            if not data:
1086                logger.error(f"Failed to get issue ID for {issue_key}")
1087                return None
1088            for issue in data[function_name]['results']:
1089                if str(issue['jira']['key']).lower() == issue_key.lower():
1090                    return issue['issueId']
1091            return None
1092        except Exception as e:
1093            logger.error(f"Failed to get issue ID for {issue_key}")
1094            logger.traceback(e)
1095            return None
1096    
1097    def get_test_details(self, issue_key: str, issue_type: str) -> Optional[str]:
1098        """
1099        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
1100        This method queries the Xray GraphQL API to find the internal issue ID corresponding
1101        to a JIRA issue key. It supports different types of Xray artifacts including test plans,
1102        test executions, test sets, and tests.
1103        Args:
1104            issue_key (str): The JIRA issue key (e.g., "PROJECT-123")
1105            issue_type (str): The type of Xray artifact. Supported values are:
1106                - "plan" or contains "plan": For Test Plans
1107                - "exec" or contains "exec": For Test Executions
1108                - "set" or contains "set": For Test Sets
1109                - "test" or contains "test": For Tests
1110                If not provided, defaults to "plan"
1111        Returns:
1112            Optional[str]: The internal Xray issue ID if found, None if:
1113                - The issue key doesn't exist
1114                - The GraphQL request fails
1115                - Any other error occurs during processing
1116        Examples:
1117            >>> client.get_issue_id_from_jira_id("TEST-123", "plan")
1118            '10000'
1119            >>> client.get_issue_id_from_jira_id("TEST-456", "test")
1120            '10001'
1121            >>> client.get_issue_id_from_jira_id("INVALID-789", "plan")
1122            None
1123        Note:
1124            The method performs a case-insensitive comparison when matching issue keys.
1125            The project key is extracted from the issue_key (text before the hyphen)
1126            to filter results by project.
1127        """
1128        try:
1129            parse_project = issue_key.split("-")[0]
1130            function_name = "getTestPlans"
1131            if not issue_type:
1132                issue_type = "plan"
1133            if "plan" in issue_type.lower():
1134                function_name = "getTestPlans"
1135                jira_fields = [
1136                    "key", "summary", "description", "assignee", 
1137                    "status", "priority", "labels", "created", 
1138                    "updated", "dueDate", "components", "versions", 
1139                    "attachments", "comments"
1140                ]
1141                query = """
1142                    query GetDetails($limit: Int!, $jql: String!) {    
1143                        getTestPlans(limit: $limit, jql:$jql) {
1144                            results {
1145                                issueId
1146                                jira(fields: ["key"])
1147                            }
1148                        }
1149                    }
1150                    """
1151            if "exec" in issue_type.lower():
1152                function_name = "getTestExecutions"
1153                jira_fields = [
1154                    "key", "summary", "description", "assignee", 
1155                    "status", "priority", "labels", "created", 
1156                    "updated", "dueDate", "components", "versions", 
1157                    "attachments", "comments"
1158                ]
1159                query = """
1160                    query GetDetails($limit: Int!, $jql: String!) {    
1161                        getTestExecutions(limit: $limit, jql:$jql) {
1162                            results {
1163                                issueId
1164                                jira(fields: ["key"])
1165                            }
1166                        }
1167                    }
1168                    """
1169            if "set" in issue_type.lower():
1170                function_name = "getTestSets"
1171                jira_fields = [
1172                    "key", "summary", "description", "assignee", 
1173                    "status", "priority", "labels", "created", 
1174                    "updated", "dueDate", "components", "versions", 
1175                    "attachments", "comments"
1176                ]
1177                query = """
1178                    query GetDetails($limit: Int!, $jql: String!) {    
1179                        getTestSets(limit: $limit, jql:$jql) {
1180                            results {
1181                                issueId
1182                                jira(fields: ["key"])
1183                            }
1184                        }
1185                    }
1186                    """
1187            if "test" in issue_type.lower():
1188                function_name = "getTests"
1189                jira_fields = [
1190                    "key", "summary", "description", "assignee", 
1191                    "status", "priority", "labels", "created", 
1192                    "updated", "dueDate", "components", "versions", 
1193                    "attachments", "comments"
1194                ]
1195                query = """
1196                    query GetDetails($limit: Int!, $jql: String!, $jiraFields: [String!]!) {    
1197                        getTests(limit: $limit, jql:$jql) {
1198                            results {
1199                                issueId
1200                                jira(fields: $jiraFields)
1201                                steps {
1202                                    id
1203                                    action
1204                                    result
1205                                    attachments {
1206                                    id
1207                                    filename
1208                                    storedInJira
1209                                    downloadLink
1210                                    }
1211                                }
1212                                
1213                            }
1214                        }
1215                    }
1216                    """
1217            variables = {
1218                "limit": 10,
1219                "jql": f"project = '{parse_project}' AND key = '{issue_key}'",
1220                "jiraFields": jira_fields
1221            }
1222            data = self._make_graphql_request(query, variables)
1223            if not data:
1224                logger.error(f"Failed to get issue ID for {issue_key}")
1225                return None
1226            for issue in data[function_name]['results']:
1227                if str(issue['jira']['key']).lower() == issue_key.lower():
1228                    return issue  # This now includes all metadata
1229            return None
1230        except Exception as e:
1231            logger.error(f"Failed to get issue ID for {issue_key}")
1232            logger.traceback(e)
1233            return None
1234    
1235    def get_tests_from_test_plan(self, test_plan: str) -> Optional[Dict[str, str]]:
1236        """
1237        Retrieves all tests associated with a given test plan from Xray.
1238        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1239        test plan. It first converts the JIRA test plan key to an internal Xray ID, then uses that
1240        ID to fetch the associated tests.
1241        Args:
1242            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1243        Returns:
1244            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1245                or None if the operation fails. For example:
1246                {
1247                    "PROJECT-124": "10001",
1248                    "PROJECT-125": "10002"
1249                }
1250                Returns None in the following cases:
1251                - Test plan ID cannot be found
1252                - GraphQL request fails
1253                - Any other error occurs during processing
1254        Example:
1255            >>> client = XrayGraphQL()
1256            >>> tests = client.get_tests_from_test_plan("PROJECT-123")
1257            >>> print(tests)
1258            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1259        Note:
1260            - The method is limited to retrieving 99999 tests per test plan
1261            - Test plan must exist in Xray and be accessible with current authentication
1262        """
1263        try:
1264            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1265            if not test_plan_id:
1266                logger.error(f"Failed to get test plan ID for {test_plan}")
1267                return None
1268            query = """
1269            query GetTestPlanTests($testPlanId: String!) {
1270                getTestPlan(issueId: $testPlanId) {
1271                    tests(limit: 99999) {
1272                        results {   
1273                            issueId
1274                            jira(fields: ["key"])
1275                        }
1276                    }
1277                }
1278            }
1279            """
1280            variables = {"testPlanId": test_plan_id}
1281            data = self._make_graphql_request(query, variables)
1282            if not data:
1283                logger.error(f"Failed to get tests for plan {test_plan_id}")
1284                return None
1285            tests = {}
1286            for test in data['getTestPlan']['tests']['results']:
1287                tests[test['jira']['key']] = test['issueId']
1288            return tests
1289        except Exception as e:
1290            logger.error(f"Failed to get tests for plan {test_plan_id}")
1291            logger.traceback(e)
1292            return None
1293    
1294    def get_tests_from_test_set(self, test_set: str) -> Optional[Dict[str, str]]:
1295        """
1296        Retrieves all tests associated with a given test set from Xray.
1297        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1298        test set. It first converts the JIRA test set key to an internal Xray ID, then uses that
1299        ID to fetch the associated tests.
1300        Args:
1301            test_set (str): The JIRA issue key of the test set (e.g., "PROJECT-123")
1302        Returns:
1303            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1304                or None if the operation fails. For example:
1305                {
1306                    "PROJECT-124": "10001",
1307                    "PROJECT-125": "10002"
1308                }
1309                Returns None in the following cases:
1310                - Test set ID cannot be found
1311                - GraphQL request fails
1312                - Any other error occurs during processing
1313        Example:
1314            >>> client = XrayGraphQL()
1315            >>> tests = client.get_tests_from_test_set("PROJECT-123")
1316            >>> print(tests)
1317            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1318        Note:
1319            - The method is limited to retrieving 99999 tests per test set
1320            - Test set must exist in Xray and be accessible with current authentication
1321        """
1322        try:
1323            test_set_id = self.get_issue_id_from_jira_id(test_set, "set")
1324            if not test_set_id:
1325                logger.error(f"Failed to get test set ID for {test_set}")
1326                return None
1327            query = """
1328            query GetTestSetTests($testSetId: String!) {
1329                getTestSet(issueId: $testSetId) {
1330                    tests(limit: 99999) {
1331                        results {   
1332                            issueId
1333                            jira(fields: ["key"])
1334                        }
1335                    }
1336                }
1337            }
1338            """
1339            variables = {"testSetId": test_set_id}
1340            data = self._make_graphql_request(query, variables)
1341            if not data:
1342                logger.error(f"Failed to get tests for set {test_set_id}")
1343                return None
1344            tests = {}
1345            for test in data['getTestSet']['tests']['results']:
1346                tests[test['jira']['key']] = test['issueId']
1347            return tests
1348        except Exception as e:
1349            logger.error(f"Failed to get tests for set {test_set_id}")
1350            logger.traceback(e)
1351            return None
1352    
1353    def get_tests_from_test_execution(self, test_execution: str) -> Optional[Dict[str, str]]:
1354        """
1355        Retrieves all tests associated with a given test execution from Xray.
1356        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1357        test execution. It first converts the JIRA test execution key to an internal Xray ID, then uses that
1358        ID to fetch the associated tests.
1359        Args:
1360            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123")
1361        Returns:
1362            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1363                or None if the operation fails. For example:
1364                {
1365                    "PROJECT-124": "10001",
1366                    "PROJECT-125": "10002"
1367                }
1368                Returns None in the following cases:
1369                - Test execution ID cannot be found
1370                - GraphQL request fails
1371                - Any other error occurs during processing
1372        Example:
1373            >>> client = XrayGraphQL()
1374            >>> tests = client.get_tests_from_test_execution("PROJECT-123")
1375            >>> print(tests)
1376            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1377        Note:
1378            - The method is limited to retrieving 99999 tests per test execution
1379            - Test execution must exist in Xray and be accessible with current authentication
1380        """
1381        try:
1382            test_execution_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1383            if not test_execution_id:
1384                logger.error(f"Failed to get test execution ID for {test_execution}")
1385                return None
1386            query = """
1387            query GetTestExecutionTests($testExecutionId: String!) {
1388                getTestExecution(issueId: $testExecutionId) {
1389                    tests(limit: 100) {
1390                        results {   
1391                            issueId
1392                            jira(fields: ["key"])
1393                        }
1394                    }
1395                }
1396            }
1397            """
1398            variables = {"testExecutionId": test_execution_id}
1399            data = self._make_graphql_request(query, variables)
1400            if not data:
1401                logger.error(f"Failed to get tests for execution {test_execution_id}")
1402                return None
1403            tests = {}
1404            for test in data['getTestExecution']['tests']['results']:
1405                tests[test['jira']['key']] = test['issueId']
1406            return tests
1407        except Exception as e:
1408            logger.error(f"Failed to get tests for execution {test_execution_id}")
1409            logger.traceback(e)
1410            return None
1411    
1412    def get_test_plan_data(self, test_plan: str) -> Optional[Dict[str, Union[List[int], List[float]]]]:
1413        """
1414        Retrieve and parse tabular data from a test plan's description field in Xray.
1415        This method fetches a test plan's description from Xray and parses any tables found within it.
1416        The tables in the description are expected to be in a specific format that can be parsed by
1417        the _parse_table method. The parsed data is returned as a dictionary containing numeric values
1418        and lists extracted from the table.
1419        Args:
1420            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1421        Returns:
1422            Optional[Dict[str, Union[List[int], List[float]]]]: A dictionary containing the parsed table data
1423                where keys are derived from the first column of the table and values are lists of numeric
1424                values. Returns None if:
1425                - The test plan ID cannot be found
1426                - The GraphQL request fails
1427                - The description cannot be parsed
1428                - Any other error occurs during processing
1429        Example:
1430            >>> client = XrayGraphQL()
1431            >>> data = client.get_test_plan_data("TEST-123")
1432            >>> print(data)
1433            {
1434                'temperature': [20, 25, 30],
1435                'pressure': [1.0, 1.5, 2.0],
1436                'measurements': [[1, 2, 3], [4, 5, 6]]
1437            }
1438        Note:
1439            - The test plan must exist in Xray and be accessible with current authentication
1440            - The description must contain properly formatted tables for parsing
1441            - Table values are converted to numeric types (int or float) where possible
1442            - Lists in table cells should be formatted as [value1, value2, ...]
1443            - Failed operations are logged as errors with relevant details
1444        """
1445        try:
1446            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1447            if not test_plan_id:
1448                logger.error(f"Failed to get test plan ID for {test_plan}")
1449                return None
1450            query = """
1451            query GetTestPlanTests($testPlanId: String!) {
1452                getTestPlan(issueId: $testPlanId) {
1453                    issueId
1454                    jira(fields: ["key","description"])
1455                }
1456            }
1457            """
1458            variables = {"testPlanId": test_plan_id}
1459            data = self._make_graphql_request(query, variables)
1460            if not data:
1461                logger.error(f"Failed to get tests for plan {test_plan_id}")
1462                return None
1463            description = data['getTestPlan']['jira']['description']
1464            test_plan_data = self._parse_table(description)
1465            return test_plan_data            
1466        except Exception as e:
1467            logger.error(f"Failed to get tests for plan {test_plan_id}")
1468            logger.traceback(e)
1469            return None
1470    
1471    def filter_test_set_by_test_case(self, test_key: str) -> Optional[Dict[str, str]]:
1472        """
1473        Retrieves all test sets that contain a specific test case from Xray.
1474        This method queries the Xray GraphQL API to find all test sets that include the specified
1475        test case. It first converts the JIRA test case key to an internal Xray ID, then uses that
1476        ID to fetch all associated test sets.
1477        Args:
1478            test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1479        Returns:
1480            Optional[Dict[str, str]]: A dictionary mapping test set JIRA keys to their summaries,
1481                or None if the operation fails. For example:
1482                {
1483                    "PROJECT-124": "Test Set for Feature A",
1484                    "PROJECT-125": "Regression Test Set"
1485                }
1486                Returns None in the following cases:
1487                - Test case ID cannot be found
1488                - GraphQL request fails
1489                - Any other error occurs during processing
1490        Example:
1491            >>> client = XrayGraphQL()
1492            >>> test_sets = client.filter_test_set_by_test_case("PROJECT-123")
1493            >>> print(test_sets)
1494            {"PROJECT-124": "Test Set for Feature A", "PROJECT-125": "Regression Test Set"}
1495        Note:
1496            - The method is limited to retrieving 99999 test sets per test case
1497            - Test case must exist in Xray and be accessible with current authentication
1498        """
1499        try:
1500            test_id = self.get_issue_id_from_jira_id(test_key, "test")
1501            if not test_id:
1502                logger.error(f"Failed to get test ID for  Test Case ({test_key})")
1503                return None
1504            query = """
1505            query GetTestDetails($testId: String!) {
1506                getTest(issueId: $testId) {
1507                    testSets(limit: 100) {
1508                        results {   
1509                            issueId
1510                            jira(fields: ["key","summary"])
1511                        }
1512                    }
1513                }
1514            }   
1515            """
1516            variables = {
1517                "testId": test_id
1518            }
1519            data = self._make_graphql_request(query, variables)
1520            if not data:
1521                logger.error(f"Failed to get tests for plan {test_id}")
1522                return None
1523            retDict = {}
1524            for test in data['getTest']['testSets']['results']:
1525                retDict[test['jira']['key']] = test['jira']['summary']
1526            return retDict
1527        except Exception as e:
1528            logger.error(f"Error in getting test set by test id: {e}")
1529            logger.traceback(e)
1530            return None 
1531    
1532    def filter_tags_by_test_case(self, test_key: str) -> Optional[List[str]]:
1533        """
1534        Extract and filter tags from test sets associated with a specific test case in Xray.
1535        This method queries the Xray GraphQL API to find all test sets associated with the given
1536        test case and extracts tags from their summaries. Tags are identified from test set summaries
1537        that start with either 'tag' or 'benchtype' prefixes.
1538        Args:
1539            test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1540        Returns:
1541            Optional[List[str]]: A list of unique tags extracted from test set summaries,
1542                or None if no tags are found or an error occurs. Tags are:
1543                - Extracted from summaries starting with 'tag:' or 'benchtype:'
1544                - Split on commas, semicolons, double pipes, or whitespace
1545                - Converted to lowercase and stripped of whitespace
1546        Example:
1547            >>> client = XrayGraphQL()
1548            >>> tags = client.filter_tags_by_test_case("TEST-123")
1549            >>> print(tags)
1550            ['regression', 'smoke', 'performance']
1551        Note:
1552            - Test sets must have summaries in the format "tag: tag1, tag2" or "benchtype: type1, type2"
1553            - Tags are extracted only from summaries with the correct prefix
1554            - All tags are converted to lowercase for consistency
1555            - Duplicate tags are automatically removed via set conversion
1556            - Returns None if no valid tags are found or if an error occurs
1557        """
1558        try:
1559            test_id = self.get_issue_id_from_jira_id(test_key, "test")
1560            if not test_id:
1561                logger.error(f"Failed to get test ID for  Test Case ({test_key})")
1562                return None
1563            query = """
1564            query GetTestDetails($testId: String!) {
1565                getTest(issueId: $testId) {
1566                    testSets(limit: 100) {
1567                        results {   
1568                            issueId
1569                            jira(fields: ["key","summary"])
1570                        }
1571                    }
1572                }
1573            }   
1574            """
1575            variables = {
1576                "testId": test_id
1577            }
1578            data = self._make_graphql_request(query, variables)
1579            if not data:
1580                logger.error(f"Failed to get tests for plan {test_id}")
1581                return None
1582            tags = set()
1583            for test in data['getTest']['testSets']['results']:
1584                summary = str(test['jira']['summary']).strip().lower()
1585                if summary.startswith(('tag', 'benchtype')):
1586                    summary = summary.split(':', 1)[-1].strip()  # Split only once and take last part
1587                    tags.update(tag for tag in re.split(r'[,|;|\|\|]\s*|\s+', summary) if tag)
1588            if tags:
1589                return list(tags)
1590            else:
1591                return None
1592        except Exception as e:
1593            logger.error(f"Error in getting test set by test id: {e}")
1594            logger.traceback(e)
1595            return None 
1596    
1597    def get_test_runstatus(self, test_case: str, test_execution: str) -> Optional[Tuple[str, str]]:
1598        """
1599        Retrieve the status of a test run for a specific test case within a test execution.
1600        This method queries the Xray GraphQL API to get the current status of a test run,
1601        which represents the execution status of a specific test case within a test execution.
1602        It first converts both the test case and test execution JIRA keys to their internal
1603        Xray IDs, then uses these to fetch the test run status.
1604        Args:
1605            test_case (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1606            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-456")
1607        Returns:
1608            Tuple[Optional[str], Optional[str]]: A tuple containing:
1609                - test_run_id: The unique identifier of the test run (or None if not found)
1610                - test_run_status: The current status of the test run (or None if not found)
1611                Returns (None, None) if any error occurs during the process.
1612        Example:
1613            >>> client = XrayGraphQL()
1614            >>> run_id, status = client.get_test_runstatus("TEST-123", "TEST-456")
1615            >>> print(f"Test Run ID: {run_id}, Status: {status}")
1616            Test Run ID: 10001, Status: PASS
1617        Note:
1618            - Both the test case and test execution must exist in Xray and be accessible
1619            - The test case must be associated with the test execution
1620            - The method performs two ID lookups before querying the test run status
1621            - Failed operations are logged as errors with relevant details
1622        """
1623        try:
1624            test_case_id = self.get_issue_id_from_jira_id(test_case, "test")
1625            if not test_case_id:
1626                logger.error(f"Failed to get test ID for Test Case ({test_case})")
1627                return None
1628            test_exec_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1629            if not test_exec_id:
1630                logger.error(f"Failed to get test execution ID for Test Execution ({test_execution})")
1631                return None
1632            query = """
1633            query GetTestRunStatus($testId: String!, $testExecutionId: String!) {
1634                getTestRun( testIssueId: $testId, testExecIssueId: $testExecutionId) {
1635                    id
1636                    status {
1637                        name
1638                    }
1639                }
1640            }
1641            """
1642            variables = {
1643                "testId": test_case_id,
1644                "testExecutionId": test_exec_id,
1645            }
1646            # Add debug loggerging
1647            logger.debug(f"Getting test run status for test {test_case_id} in execution {test_exec_id}")
1648            data = self._make_graphql_request(query, variables)
1649            if not data:
1650                logger.error(f"Failed to get test run status for test {test_case_id}")
1651                return None
1652            # jprint(data)
1653            test_run_id = data['getTestRun']['id']
1654            test_run_status = data['getTestRun']['status']['name']
1655            return (test_run_id, test_run_status)
1656        except Exception as e:
1657            logger.error(f"Error getting test run status: {str(e)}")
1658            logger.traceback(e)
1659            return (None, None)
1660    
1661    def get_test_run_by_id(self, test_case_id: str, test_execution_id: str) -> Optional[Tuple[str, str]]:
1662        """
1663        Retrieves the test run ID and status for a specific test case within a test execution using GraphQL.
1664        Args:
1665            test_case_id (str): The ID of the test case to query
1666            test_execution_id (str): The ID of the test execution containing the test run
1667        Returns:
1668            tuple[Optional[str], Optional[str]]: A tuple containing:
1669                - test_run_id: The ID of the test run if found, None if not found or on error
1670                - test_run_status: The status name of the test run if found, None if not found or on error
1671        Note:
1672            The function makes a GraphQL request to fetch the test run information. If the request fails
1673            or encounters any errors, it will log the error and return (None, None).
1674        """
1675        try:
1676            query = """
1677            query GetTestRunStatus($testId: String!, $testExecutionId: String!) {
1678                getTestRun( testIssueId: $testId, testExecIssueId: $testExecutionId) {
1679                    id
1680                    status {
1681                        name
1682                    }
1683                }
1684            }
1685            """
1686            variables = {
1687                "testId": test_case_id,
1688                "testExecutionId": test_execution_id,
1689            }
1690            # Add debug loggerging
1691            logger.debug(f"Getting test run status for test {test_case_id} in execution {test_execution_id}")
1692            data = self._make_graphql_request(query, variables)
1693            if not data:
1694                logger.error(f"Failed to get test run status for test {test_case_id}")
1695                return None
1696            test_run_id = data['getTestRun']['id']
1697            test_run_status = data['getTestRun']['status']['name']
1698            return (test_run_id, test_run_status)
1699        except Exception as e:
1700            logger.error(f"Error getting test run status: {str(e)}")
1701            logger.traceback(e)
1702            return (None, None)
1703    
1704    def get_test_execution(self, test_execution: str) -> Optional[Dict]:
1705        """
1706        Retrieve detailed information about a test execution from Xray.
1707        This method queries the Xray GraphQL API to fetch information about a specific test execution,
1708        including its ID and associated tests. It first converts the JIRA test execution key to an
1709        internal Xray ID, then uses that ID to fetch the execution details.
1710        Args:
1711            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123")
1712        Returns:
1713            Optional[Dict]: A dictionary containing test execution details if successful, None if failed.
1714                The dictionary has the following structure:
1715                {
1716                    'id': str,          # The internal Xray ID of the test execution
1717                    'tests': {          # Dictionary mapping test keys to their IDs
1718                        'TEST-124': '10001',
1719                        'TEST-125': '10002',
1720                        ...
1721                    }
1722                }
1723                Returns None in the following cases:
1724                - Test execution ID cannot be found
1725                - GraphQL request fails
1726                - No test execution found with the given ID
1727                - No tests found in the test execution
1728                - Any other error occurs during processing
1729        Example:
1730            >>> client = XrayGraphQL()
1731            >>> execution = client.get_test_execution("TEST-123")
1732            >>> print(execution)
1733            {
1734                'id': '10000',
1735                'tests': {
1736                    'TEST-124': '10001',
1737                    'TEST-125': '10002'
1738                }
1739            }
1740        Note:
1741            - The method is limited to retrieving 99999 tests per test execution
1742            - Test execution must exist in Xray and be accessible with current authentication
1743            - Failed operations are logged with appropriate error or warning messages
1744        """
1745        try:
1746            test_execution_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1747            if not test_execution_id:
1748                logger.error(f"Failed to get test execution ID for {test_execution}")
1749                return None
1750            query = """
1751            query GetTestExecution($testExecutionId: String!) {
1752                getTestExecution(issueId: $testExecutionId) {
1753                    issueId
1754                    projectId
1755                    jira(fields: ["key", "summary", "description", "status"])
1756                    tests(limit: 100) {
1757                        total
1758                        start
1759                        limit
1760                        results {
1761                            issueId
1762                            jira(fields: ["key"])
1763                        }
1764                    }
1765                }
1766            }
1767            """
1768            variables = {
1769                "testExecutionId": test_execution_id
1770            }
1771            # Add debug loggerging
1772            logger.debug(f"Getting test execution details for {test_execution_id}")
1773            data = self._make_graphql_request(query, variables)
1774            # jprint(data)
1775            if not data:
1776                logger.error(f"Failed to get test execution details for {test_execution_id}")
1777                return None
1778            test_execution = data.get('getTestExecution',{})
1779            if not test_execution:
1780                logger.warning(f"No test execution found with ID {test_execution_id}")
1781                return None
1782            tests = test_execution.get('tests',{})
1783            if not tests:
1784                logger.warning(f"No tests found for test execution {test_execution_id}")
1785                return None
1786            tests_details = dict()
1787            for test in tests['results']:
1788                tests_details[test['jira']['key']] = test['issueId']
1789            formatted_response = {
1790                'id': test_execution['issueId'],
1791                'tests': tests_details
1792            }
1793            return formatted_response
1794        except Exception as e:
1795            logger.error(f"Error getting test execution details: {str(e)}")
1796            logger.traceback(e)
1797            return None
1798    
1799    def add_test_execution_to_test_plan(self, test_plan: str, test_execution: str) -> Optional[Dict]:
1800        """
1801        Add a test execution to an existing test plan in Xray.
1802        This method associates a test execution with a test plan using the Xray GraphQL API.
1803        It first converts both the test plan and test execution JIRA keys to their internal
1804        Xray IDs, then creates the association between them.
1805        Args:
1806            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1807            test_execution (str): The JIRA issue key of the test execution to add (e.g., "PROJECT-456")
1808        Returns:
1809            Optional[Dict]: A dictionary containing the response data if successful, None if failed.
1810                The dictionary has the following structure:
1811                {
1812                    'addTestExecutionsToTestPlan': {
1813                        'addedTestExecutions': [str],  # List of added test execution IDs
1814                        'warning': str                 # Any warnings from the operation
1815                    }
1816                }
1817                Returns None in the following cases:
1818                - Test plan ID cannot be found
1819                - Test execution ID cannot be found
1820                - GraphQL request fails
1821                - Any other error occurs during processing
1822        Example:
1823            >>> client = XrayGraphQL()
1824            >>> result = client.add_test_execution_to_test_plan("TEST-123", "TEST-456")
1825            >>> print(result)
1826            {
1827                'addTestExecutionsToTestPlan': {
1828                    'addedTestExecutions': ['10001'],
1829                    'warning': None
1830                }
1831            }
1832        Note:
1833            - Both the test plan and test execution must exist in Xray and be accessible
1834            - The method performs two ID lookups before creating the association
1835            - Failed operations are logged as errors with relevant details
1836        """
1837        try:
1838            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1839            if not test_plan_id:
1840                logger.error(f"Test plan ID is required")
1841                return None
1842            test_exec_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1843            if not test_exec_id:
1844                logger.error(f"Test execution ID is required")
1845                return None
1846            query = """
1847            mutation AddTestExecutionToTestPlan($testPlanId: String!, $testExecutionIds: [String!]!) {
1848                addTestExecutionsToTestPlan(issueId: $testPlanId, testExecIssueIds: $testExecutionIds) {
1849                    addedTestExecutions 
1850                    warning
1851                }
1852            }
1853            """
1854            variables = {
1855                "testPlanId": test_plan_id,
1856                "testExecutionIds": [test_exec_id]
1857            }
1858            data = self._make_graphql_request(query, variables)
1859            return data
1860        except Exception as e:
1861            logger.error(f"Error adding test execution to test plan: {str(e)}")
1862            logger.traceback(e)
1863            return None
1864    
1865    def create_test_execution(self, 
1866                            test_issue_keys: List[str], 
1867                            project_key: Optional[str] = None, 
1868                            summary: Optional[str] = None, 
1869                            description: Optional[str] = None) -> Optional[Dict]:
1870        """
1871        Create a new test execution in Xray with specified test cases.
1872        This method creates a test execution ticket in JIRA/Xray that includes the specified test cases.
1873        It handles validation of test issue keys, automatically derives project information if not provided,
1874        and creates appropriate default values for summary and description if not specified.
1875        Args:
1876            test_issue_keys (List[str]): List of JIRA issue keys for test cases to include in the execution
1877                (e.g., ["TEST-123", "TEST-124"])
1878            project_key (Optional[str]): The JIRA project key where the test execution should be created.
1879                If not provided, it will be derived from the first test issue key.
1880            summary (Optional[str]): The summary/title for the test execution ticket.
1881                If not provided, a default summary will be generated using the test issue keys.
1882            description (Optional[str]): The description for the test execution ticket.
1883                If not provided, a default description will be generated using the test issue keys.
1884        Returns:
1885            Optional[Dict]: A dictionary containing the created test execution details if successful,
1886                None if the creation fails. The dictionary has the following structure:
1887                {
1888                    'issueId': str,      # The internal Xray ID of the created test execution
1889                    'jira': {
1890                        'key': str       # The JIRA issue key of the created test execution
1891                    }
1892                }
1893        Example:
1894            >>> client = XrayGraphQL()
1895            >>> test_execution = client.create_test_execution(
1896            ...     test_issue_keys=["TEST-123", "TEST-124"],
1897            ...     project_key="TEST",
1898            ...     summary="Sprint 1 Regression Tests"
1899            ... )
1900            >>> print(test_execution)
1901            {'issueId': '10001', 'jira': {'key': 'TEST-125'}}
1902        Note:
1903            - Invalid test issue keys are logged as warnings but don't prevent execution creation
1904            - At least one valid test issue key is required
1905            - The method validates each test issue key before creating the execution
1906            - Project key is automatically derived from the first test issue key if not provided
1907        """
1908        try:
1909            invalid_keys = []
1910            test_issue_ids = []
1911            for key in test_issue_keys:
1912                test_issue_id = self.get_issue_id_from_jira_id(key, "test")
1913                if test_issue_id:
1914                    test_issue_ids.append(test_issue_id)
1915                else:
1916                    invalid_keys.append(key)
1917            if len(test_issue_ids) == 0:
1918                logger.error(f"No valid test issue keys provided: {invalid_keys}")
1919                return None
1920            if len(invalid_keys) > 0:
1921                logger.warning(f"Invalid test issue keys: {invalid_keys}")
1922            if not project_key:
1923                project_key = test_issue_keys[0].split("-")[0]
1924            if not summary:
1925                summary = f"Test Execution for Test Plan {test_issue_keys}"
1926            if not description:
1927                description = f"Test Execution for Test Plan {test_issue_keys}"
1928            mutation = """
1929            mutation CreateTestExecutionForTestPlan(
1930                $testIssueId_list: [String!]!,
1931                $projectKey: String!,
1932                $summary: String!,
1933                $description: String
1934            ) {
1935                createTestExecution(
1936                    testIssueIds: $testIssueId_list,
1937                    jira: {
1938                        fields: {
1939                            project: { key: $projectKey },
1940                            summary: $summary,
1941                            description: $description,
1942                            issuetype: { name: "Test Execution" }
1943                        }
1944                    }
1945                ) {
1946                    testExecution {
1947                        issueId
1948                        jira(fields: ["key"])
1949                    }
1950                    warnings
1951                }
1952            }
1953            """
1954            variables = {
1955                "testIssueId_list": test_issue_ids,
1956                "projectKey": project_key,
1957                "summary": summary,
1958                "description": description
1959            }
1960            data = self._make_graphql_request(mutation, variables)
1961            if not data:
1962                return None
1963            execution_details = data['createTestExecution']['testExecution']
1964            # logger.info(f"Created test execution {execution_details['jira']['key']}")
1965            return execution_details
1966        except Exception as e:
1967            logger.error("Failed to create test execution : {e}")
1968            logger.traceback(e)
1969            return None
1970    
1971    def create_test_execution_from_test_plan(self, test_plan: str) -> Optional[Dict[str, Dict[str, str]]]:
1972        """Creates a test execution from a given test plan and associates all tests from the plan with the execution.
1973        This method performs several operations in sequence:
1974        1. Retrieves all tests from the specified test plan
1975        2. Creates a new test execution with those tests
1976        3. Associates the new test execution with the original test plan
1977        4. Creates test runs for each test in the execution
1978        Parameters
1979        ----------
1980        test_plan : str
1981            The JIRA issue key of the test plan (e.g., "PROJECT-123")
1982        Returns
1983        -------
1984        Optional[Dict[str, Dict[str, str]]]
1985            A dictionary mapping test case keys to their execution details, or None if the operation fails.
1986            The dictionary structure is::
1987                {
1988                    "TEST-123": {                    # Test case JIRA key
1989                        "test_run_id": "12345",      # Unique ID for this test run
1990                        "test_execution_key": "TEST-456",  # JIRA key of the created test execution
1991                        "test_plan_key": "TEST-789"  # Original test plan JIRA key
1992                    },
1993                    "TEST-124": {
1994                        ...
1995                    }
1996                }
1997            Returns None in the following cases:
1998            * Test plan parameter is empty or invalid
1999            * No tests found in the test plan
2000            * Test execution creation fails
2001            * API request fails
2002        Examples
2003        --------
2004        >>> client = XrayGraphQL()
2005        >>> result = client.create_test_execution_from_test_plan("TEST-123")
2006        >>> print(result)
2007        {
2008            "TEST-124": {
2009                "test_run_id": "5f7c3",
2010                "test_execution_key": "TEST-456",
2011                "test_plan_key": "TEST-123"
2012            },
2013            "TEST-125": {
2014                "test_run_id": "5f7c4",
2015                "test_execution_key": "TEST-456",
2016                "test_plan_key": "TEST-123"
2017            }
2018        }
2019        Notes
2020        -----
2021        - The test plan must exist and be accessible in Xray
2022        - All tests in the test plan must be valid and accessible
2023        - The method automatically generates a summary and description for the test execution
2024        - The created test execution is automatically linked back to the original test plan
2025        """
2026        try:
2027            if not test_plan:
2028                logger.error("Test plan is required [ jira key]")
2029                return None
2030            project_key = test_plan.split("-")[0]
2031            summary = f"Test Execution for Test Plan {test_plan}"
2032            retDict = dict()
2033            #Get tests from test plan
2034            tests = self.get_tests_from_test_plan(test_plan)
2035            retDict["tests"] = tests
2036            testIssueId_list = list(tests.values())
2037            # logger.info(f"Tests: {tests}")
2038            if not testIssueId_list:
2039                logger.error(f"No tests found for {test_plan}")
2040                return None
2041            description = f"Test Execution for {len(tests)} Test cases"
2042            # GraphQL mutation to create test execution
2043            query = """
2044                mutation CreateTestExecutionForTestPlan(
2045                    $testIssueId_list: [String!]!,
2046                    $projectKey: String!,
2047                    $summary: String!,
2048                    $description: String
2049                ) {
2050                    createTestExecution(
2051                        testIssueIds: $testIssueId_list,
2052                        jira: {
2053                            fields: {
2054                                project: { key: $projectKey },
2055                                summary: $summary,
2056                                description: $description,
2057                                issuetype: { name: "Test Execution" }
2058                            }
2059                        }
2060                    ) {
2061                        testExecution {
2062                            issueId
2063                            jira(fields: ["key"])
2064                            testRuns(limit: 100) {
2065                                results {
2066                                    id
2067                                    test {
2068                                        issueId
2069                                        jira(fields: ["key"])
2070                                    }
2071                                }
2072                            }
2073                        }
2074                        warnings
2075                    }
2076                }
2077            """
2078            variables = {
2079                "testIssueId_list": testIssueId_list,
2080                "projectKey": project_key,
2081                "summary": summary,
2082                "description": description or f"Test execution for total of {len(testIssueId_list)} test cases"
2083            }
2084            data = self._make_graphql_request(query, variables)
2085            if not data:
2086                return None
2087            test_exec_key = data['createTestExecution']['testExecution']['jira']['key']
2088            test_exec_id = data['createTestExecution']['testExecution']['issueId']
2089            #Add Test execution to test plan
2090            test_exec_dict= self.add_test_execution_to_test_plan(test_plan, test_exec_key)
2091            #Get test runs for test execution
2092            test_runs = data['createTestExecution']['testExecution']['testRuns']['results']
2093            test_run_dict = dict()
2094            for test_run in test_runs:
2095                test_run_dict[test_run['test']['jira']['key']] = dict()
2096                test_run_dict[test_run['test']['jira']['key']]['test_run_id'] = test_run['id']
2097                # test_run_dict[test_run['test']['jira']['key']]['test_issue_id'] = test_run['test']['issueId']
2098                test_run_dict[test_run['test']['jira']['key']]['test_execution_key'] = test_exec_key
2099                # test_run_dict[test_run['test']['jira']['key']]['test_execution_id'] = test_exec_id
2100                test_run_dict[test_run['test']['jira']['key']]['test_plan_key'] = test_plan
2101            return test_run_dict
2102        except requests.exceptions.RequestException as e:
2103            logger.error(f"Error creating test execution: {e}")
2104            logger.traceback(e)
2105        return None
2106    
2107    def update_test_run_status(self, test_run_id: str, test_run_status: str) -> bool:
2108        """
2109        Update the status of a specific test run in Xray using the GraphQL API.
2110        This method allows updating the execution status of a test run identified by its ID.
2111        The status can be changed to reflect the current state of the test execution
2112        (e.g., "PASS", "FAIL", "TODO", etc.).
2113        Args:
2114            test_run_id (str): The unique identifier of the test run to update.
2115                This is the internal Xray ID for the test run, not the Jira issue key.
2116            test_run_status (str): The new status to set for the test run.
2117                Common values include: "PASS", "FAIL", "TODO", "EXECUTING", etc.
2118        Returns:
2119            bool: True if the status update was successful, False otherwise.
2120                Returns None if an error occurs during the API request.
2121        Example:
2122            >>> client = XrayGraphQL()
2123            >>> test_run_id = client.get_test_run_status("TEST-CASE-KEY", "TEST-EXEC-KEY")
2124            >>> success = client.update_test_run_status(test_run_id, "PASS")
2125            >>> print(success)
2126            True
2127        Note:
2128            - The test run ID must be valid and accessible with current authentication
2129            - The status value should be one of the valid status values configured in your Xray instance
2130            - Failed updates are logged as errors with details about the failure
2131        Raises:
2132            Exception: If there is an error making the GraphQL request or processing the response.
2133                The exception is caught and logged, and the method returns None.
2134        """
2135        try:
2136            query = """
2137            mutation UpdateTestRunStatus($testRunId: String!, $status: String!) {
2138                updateTestRunStatus(
2139                    id: $testRunId, 
2140                    status: $status 
2141                ) 
2142            }                       
2143            """
2144            variables = {
2145                "testRunId": test_run_id,
2146                "status": test_run_status
2147            }
2148            data = self._make_graphql_request(query, variables)
2149            if not data:
2150                logger.error(f"Failed to get test run status for test {data}")
2151                return None
2152            # logger.info(f"Test run status updated: {data}")
2153            return data['updateTestRunStatus']
2154        except Exception as e:
2155            logger.error(f"Error updating test run status: {str(e)}")
2156            logger.traceback(e)
2157            return None
2158    
2159    def update_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2160        """
2161        Update the comment of a specific test run in Xray using the GraphQL API.
2162        This method allows adding or updating the comment associated with a test run
2163        identified by its ID. The comment can provide additional context, test results,
2164        or any other relevant information about the test execution.
2165        Args:
2166            test_run_id (str): The unique identifier of the test run to update.
2167                This is the internal Xray ID for the test run, not the Jira issue key.
2168            test_run_comment (str): The new comment text to set for the test run.
2169                This will replace any existing comment on the test run.
2170        Returns:
2171            bool: True if the comment update was successful, False otherwise.
2172                Returns None if an error occurs during the API request.
2173        Example:
2174            >>> client = XrayGraphQL()
2175            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2176            >>> success = client.update_test_run_comment(
2177            ...     test_run_id,
2178            ...     "Test passed with performance within expected range"
2179            ... )
2180            >>> print(success)
2181            True
2182        Note:
2183            - The test run ID must be valid and accessible with current authentication
2184            - The comment can include any text content, including newlines and special characters
2185            - Failed updates are logged as errors with details about the failure
2186            - This method will overwrite any existing comment on the test run
2187        Raises:
2188            Exception: If there is an error making the GraphQL request or processing the response.
2189                The exception is caught and logged, and the method returns None.
2190        """
2191        try:
2192            query = """
2193            mutation UpdateTestRunStatus($testRunId: String!, $comment: String!) {
2194                updateTestRunComment(
2195                    id: $testRunId, 
2196                    comment: $comment 
2197                ) 
2198            }                       
2199            """
2200            variables = {
2201                "testRunId": test_run_id,
2202                "comment": test_run_comment
2203            }
2204            data = self._make_graphql_request(query, variables)
2205            if not data:
2206                logger.error(f"Failed to get test run comment for test {data}")
2207                return None
2208            # jprint(data)
2209            return data['updateTestRunComment']
2210        except Exception as e:
2211            logger.error(f"Error updating test run comment: {str(e)}")
2212            logger.traceback(e)
2213            return None
2214    
2215    def add_evidence_to_test_run(self, test_run_id: str, evidence_path: str) -> bool:
2216        """Add evidence (attachments) to a test run in Xray.
2217        This method allows attaching files as evidence to a specific test run. The file is
2218        read, converted to base64, and uploaded to Xray with appropriate MIME type detection.
2219        Parameters
2220        ----------
2221        test_run_id : str
2222            The unique identifier of the test run to add evidence to
2223        evidence_path : str
2224            The local file system path to the evidence file to be attached
2225        Returns
2226        -------
2227        bool
2228            True if the evidence was successfully added, None if the operation failed.
2229            Returns None in the following cases:
2230            - Test run ID is not provided
2231            - Evidence path is not provided
2232            - Evidence file does not exist
2233            - GraphQL request fails
2234            - Any other error occurs during processing
2235        Examples
2236        --------
2237        >>> client = XrayGraphQL()
2238        >>> success = client.add_evidence_to_test_run(
2239        ...     test_run_id="10001",
2240        ...     evidence_path="/path/to/screenshot.png"
2241        ... )
2242        >>> print(success)
2243        True
2244        Notes
2245        -----
2246        - The evidence file must exist and be accessible
2247        - The file is automatically converted to base64 for upload
2248        - MIME type is automatically detected, defaults to "text/plain" if detection fails
2249        - The method supports various file types (images, documents, logs, etc.)
2250        - Failed operations are logged with appropriate error messages
2251        """
2252        try:
2253            if not test_run_id:
2254                logger.error("Test run ID is required")
2255                return None
2256            if not evidence_path:
2257                logger.error("Evidence path is required")
2258                return None
2259            if not os.path.exists(evidence_path):
2260                logger.error(f"Evidence file not found: {evidence_path}")
2261                return None
2262            #if file exists then read the file in base64
2263            evidence_base64 = None
2264            mime_type = None
2265            filename = os.path.basename(evidence_path)
2266            with open(evidence_path, "rb") as file:
2267                evidence_data = file.read()
2268                evidence_base64 = base64.b64encode(evidence_data).decode('utf-8')
2269                mime_type = mimetypes.guess_type(evidence_path)[0]
2270                logger.info(f"For loop -- Mime type: {mime_type}")
2271                if not mime_type:
2272                    mime_type = "text/plain"
2273            query = """
2274            mutation AddEvidenceToTestRun($testRunId: String!, $filename: String!, $mimeType: String!, $evidenceBase64: String!) {
2275                addEvidenceToTestRun(
2276                    id: $testRunId, 
2277                    evidence: [
2278                        {
2279                            filename : $filename,
2280                            mimeType : $mimeType,
2281                            data : $evidenceBase64
2282                        }
2283                    ]
2284                ) {
2285                    addedEvidence
2286                    warnings
2287                }
2288            }
2289            """
2290            variables = {
2291                "testRunId": test_run_id,
2292                "filename": filename,
2293                "mimeType": mime_type,
2294                "evidenceBase64": evidence_base64
2295            }
2296            data = self._make_graphql_request(query, variables) 
2297            if not data:
2298                logger.error(f"Failed to add evidence to test run: {data}")
2299                return None
2300            return data['addEvidenceToTestRun'] 
2301        except Exception as e:
2302            logger.error(f"Error adding evidence to test run: {str(e)}")
2303            logger.traceback(e)
2304            return None
2305    
2306    def create_defect_from_test_run(self, test_run_id: str, project_key: str, parent_issue_key: str, defect_summary: str, defect_description: str) -> Optional[Dict]:
2307        """Create a defect from a test run and link it to the test run in Xray.
2308        This method performs two main operations:
2309        1. Creates a new defect in JIRA with the specified summary and description
2310        2. Links the created defect to the specified test run in Xray
2311        Parameters
2312        ----------
2313        test_run_id : str
2314            The ID of the test run to create defect from
2315        project_key : str
2316            The JIRA project key where the defect should be created.
2317            If not provided, defaults to "EAGVAL"
2318        parent_issue_key : str
2319            The JIRA key of the parent issue to link the defect to
2320        defect_summary : str
2321            Summary/title of the defect.
2322            If not provided, defaults to "Please provide a summary for the defect"
2323        defect_description : str
2324            Description of the defect.
2325            If not provided, defaults to "Please provide a description for the defect"
2326        Returns
2327        -------
2328        Optional[Dict]
2329            Response data from the GraphQL API if successful, None if failed.
2330            The response includes:
2331            - addedDefects: List of added defects
2332            - warnings: Any warnings from the operation
2333        Examples
2334        --------
2335        >>> client = XrayGraphQL()
2336        >>> result = client.create_defect_from_test_run(
2337        ...     test_run_id="10001",
2338        ...     project_key="PROJ",
2339        ...     parent_issue_key="PROJ-456",
2340        ...     defect_summary="Test failure in login flow",
2341        ...     defect_description="The login button is not responding to clicks"
2342        ... )
2343        >>> print(result)
2344        {
2345            'addedDefects': ['PROJ-123'],
2346            'warnings': []
2347        }
2348        Notes
2349        -----
2350        - The project_key will be split on '-' and only the first part will be used
2351        - The defect will be created with issue type 'Bug'
2352        - The method handles missing parameters with default values
2353        - The parent issue must exist and be accessible to create the defect
2354        """
2355        try:
2356            if not project_key:
2357                project_key = "EAGVAL"
2358            if not defect_summary:
2359                defect_summary = "Please provide a summary for the defect"
2360            if not defect_description:
2361                defect_description = "Please provide a description for the defect"
2362            project_key = project_key.split("-")[0]
2363            # Fix: Correct parameter order for create_issue
2364            defect_key, defect_id = self.create_issue(
2365                project_key=project_key,
2366                parent_issue_key=parent_issue_key,
2367                summary=defect_summary,
2368                description=defect_description,
2369                issue_type='Bug'
2370            )
2371            if not defect_key:
2372                logger.error("Failed to create defect issue")
2373                return None
2374            # Then add the defect to the test run
2375            add_defect_mutation = """
2376            mutation AddDefectsToTestRun(
2377                $testRunId: String!,
2378                $defectKey: String!
2379            ) {
2380                addDefectsToTestRun( id: $testRunId, issues: [$defectKey]) {
2381                    addedDefects
2382                    warnings
2383                }
2384            }
2385            """
2386            variables = {
2387                "testRunId": test_run_id,
2388                "defectKey": defect_key
2389            }
2390            data = None
2391            retry_count = 0
2392            while retry_count < 3:
2393                data = self._make_graphql_request(add_defect_mutation, variables)
2394                if not data:
2395                    logger.error(f"Failed to add defect [{defect_key}] to test run - {test_run_id}.. retrying... {retry_count}")
2396                    retry_count += 1
2397                    time.sleep(1)
2398                else:
2399                    break
2400            return data
2401        except Exception as e:
2402            logger.error(f"Error creating defect from test run: {str(e)}")
2403            logger.traceback(e)
2404            return None
2405    
2406    def get_test_run_comment(self, test_run_id: str) -> Optional[str]:
2407        """
2408        Retrieve the comment of a specific test run from Xray using the GraphQL API.
2409        This method allows retrieving the comment associated with a test run
2410        identified by its ID. The comment can provide additional context, test results,
2411        or any other relevant information about the test execution.
2412        Args:
2413            test_run_id (str): The unique identifier of the test run to retrieve comment from.
2414                This is the internal Xray ID for the test run, not the Jira issue key.
2415        Returns:
2416            Optional[str]: The comment text of the test run if successful, None if:
2417                - The test run ID is not found
2418                - The GraphQL request fails
2419                - No comment exists for the test run
2420                - Any other error occurs during the API request
2421        Example:
2422            >>> client = XrayGraphQL()
2423            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2424            >>> comment = client.get_test_run_comment(test_run_id)
2425            >>> print(comment)
2426            "Test passed with performance within expected range"
2427        Note:
2428            - The test run ID must be valid and accessible with current authentication
2429            - If no comment exists for the test run, the method will return None
2430            - Failed requests are logged as errors with details about the failure
2431            - The method returns the raw comment text as stored in Xray
2432        Raises:
2433            Exception: If there is an error making the GraphQL request or processing the response.
2434                The exception is caught and logged, and the method returns None.
2435        """
2436        try:
2437            # Try the direct ID approach first
2438            query = """
2439            query GetTestRunComment($testRunId: String!) {
2440                getTestRunById(id: $testRunId) {
2441                    id
2442                    comment
2443                    status {
2444                        name
2445                    }
2446                }
2447            }                       
2448            """
2449            variables = {
2450                "testRunId": test_run_id
2451            }
2452            data = self._make_graphql_request(query, variables)
2453            jprint(data)
2454            if not data:
2455                logger.error(f"Failed to get test run comment for test run {test_run_id}")
2456                return None
2457            test_run = data.get('getTestRunById', {})
2458            if not test_run:
2459                logger.warning(f"No test run found with ID {test_run_id}")
2460                return None
2461            comment = test_run.get('comment')
2462            return comment
2463        except Exception as e:
2464            logger.error(f"Error getting test run comment: {str(e)}")
2465            logger.traceback(e)
2466            return None
2467    
2468    def append_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2469        """
2470        Append the comment of a specific test run in Xray using the GraphQL API.
2471        This method allows appending the comment associated with a test run
2472        identified by its ID. The comment can provide additional context, test results,
2473        or any other relevant information about the test execution.
2474        Args:
2475            test_run_id (str): The unique identifier of the test run to update.
2476                This is the internal Xray ID for the test run, not the Jira issue key.
2477            test_run_comment (str): The comment text to append to the test run.
2478                This will be added to any existing comment on the test run with proper formatting.
2479        Returns:
2480            bool: True if the comment update was successful, False otherwise.
2481                Returns None if an error occurs during the API request.
2482        Example:
2483            >>> client = XrayGraphQL()
2484            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2485            >>> success = client.append_test_run_comment(
2486            ...     test_run_id,
2487            ...     "Test passed with performance within expected range"
2488            ... )
2489            >>> print(success)
2490            True
2491        Note:
2492            - The test run ID must be valid and accessible with current authentication
2493            - The comment can include any text content, including newlines and special characters
2494            - Failed updates are logged as errors with details about the failure
2495            - This method will append to existing comments with proper line breaks
2496            - If no existing comment exists, the new comment will be set as the initial comment
2497        Raises:
2498            Exception: If there is an error making the GraphQL request or processing the response.
2499                The exception is caught and logged, and the method returns None.
2500        """
2501        try:
2502            # Get existing comment
2503            existing_comment = self.get_test_run_comment(test_run_id)
2504            # Prepare the combined comment with proper formatting
2505            if existing_comment:
2506                # If there's an existing comment, append with double newline for proper separation
2507                combined_comment = f"{existing_comment}\n{test_run_comment}"
2508            else:
2509                # If no existing comment, use the new comment as is
2510                combined_comment = test_run_comment
2511                logger.debug(f"No existing comment found for test run {test_run_id}, setting initial comment")
2512            query = """
2513            mutation UpdateTestRunComment($testRunId: String!, $comment: String!) {
2514                updateTestRunComment(
2515                    id: $testRunId, 
2516                    comment: $comment 
2517                ) 
2518            }                       
2519            """
2520            variables = {
2521                "testRunId": test_run_id,
2522                "comment": combined_comment
2523            }
2524            data = self._make_graphql_request(query, variables)
2525            if not data:
2526                logger.error(f"Failed to update test run comment for test run {test_run_id}")
2527                return None
2528            return data['updateTestRunComment']
2529        except Exception as e:
2530            logger.error(f"Error updating test run comment: {str(e)}")
2531            logger.traceback(e)
2532            return None
2533
2534    def download_attachment(self, jira_key: str, file_extension: str) -> Optional[Dict]:
2535        '''
2536        Download a JIRA attachment by its ID.
2537        '''
2538        try:
2539            response = self.make_jira_request(jira_key, 'GET')
2540            
2541            if not response or 'fields' not in response:
2542                logger.error(f"Error: Could not retrieve issue {jira_key}")
2543                return None
2544            
2545            # Find attachment by filename
2546            attachments = response.get('fields', {}).get('attachment', [])
2547            target_attachment = [att for att in attachments if att.get('filename').endswith(file_extension)]
2548            if not target_attachment:
2549                logger.error(f"No attachment found for {jira_key} with extension {file_extension}")
2550                return None
2551            
2552            combined_attachment = []
2553            for attachment in target_attachment:
2554                attachment_id = attachment.get('id')
2555                mime_type = attachment.get('mimeType', '')
2556                combined_attachment.append(self.download_jira_attachment_by_id(attachment_id, mime_type))
2557            
2558            return combined_attachment
2559        except Exception as e:
2560            logger.error(f"Error downloading JIRA attachment: {str(e)}")
2561            logger.traceback(e)
2562            return None
2563        
class JiraHandler:
 13class JiraHandler():
 14    """A handler class for interacting with JIRA's REST API.
 15    This class provides methods to interact with JIRA's REST API, handling authentication,
 16    issue creation, retrieval, and various JIRA operations. It uses environment variables for
 17    configuration and provides robust error handling with comprehensive logging.
 18    The class supports creating issues with attachments, linking issues, and retrieving
 19    detailed issue information with customizable field selection. It handles various
 20    JIRA field types including user information, attachments, comments, and issue links.
 21    Attributes
 22    ----------
 23    client : JIRA
 24        The JIRA client instance used for API interactions
 25    Environment Variables
 26    --------------------
 27    JIRA_SERVER : str
 28        The JIRA server URL (default: 'https://arusa.atlassian.net')
 29    JIRA_USER : str
 30        The JIRA user email (default: 'yakub@arusatech.com')
 31    JIRA_API_KEY : str
 32        The JIRA API key for authentication (required)
 33    Methods
 34    -------
 35    create_issue(project_key, summary, description, **kwargs)
 36        Create a new JIRA issue with optional attachments, linking, and custom fields
 37    get_issue(issue_key, fields=None)
 38        Retrieve a JIRA issue with specified fields or all available fields
 39    Examples
 40    --------
 41    >>> handler = JiraHandler()
 42    >>> # Create a new issue
 43    >>> issue_key, issue_id = handler.create_issue(
 44    ...     project_key="PROJ",
 45    ...     summary="New feature implementation",
 46    ...     description="Implement new login flow",
 47    ...     issue_type="Story",
 48    ...     priority="High",
 49    ...     labels=["feature", "login"],
 50    ...     attachments=["/path/to/screenshot.png"]
 51    ... )
 52    >>> print(f"Created issue {issue_key} with ID {issue_id}")
 53    >>> # Retrieve issue details
 54    >>> issue = handler.get_issue("PROJ-123", fields=["summary", "status", "assignee"])
 55    >>> print(f"Issue: {issue['summary']} - Status: {issue['status']['name']}")
 56    Notes
 57    -----
 58    - Requires valid JIRA credentials stored in environment variables
 59    - Automatically loads configuration from .env file if present
 60    - Provides comprehensive error handling and logging
 61    - Supports various JIRA field types and custom fields
 62    - Handles file attachments with automatic MIME type detection
 63    - Creates issue links with configurable link types
 64    - Returns None for failed operations instead of raising exceptions
 65    """
 66    
 67    def __init__(self):
 68        """Initialize the JIRA client with configuration from environment variables.
 69        This constructor sets up the JIRA client by reading configuration from
 70        environment variables. It automatically loads variables from a .env file
 71        if present in the project root.
 72        Environment Variables
 73        -------------------
 74        JIRA_SERVER : str
 75            The JIRA server URL (default: 'https://arusatech.atlassian.net')
 76        JIRA_USER : str
 77            The JIRA user email (default: 'yakub@arusatech.com')
 78        JIRA_API_KEY : str
 79            The JIRA API key for authentication
 80        Raises
 81        ------
 82        Exception
 83            If the JIRA client initialization fails
 84        """
 85        try:
 86            # Load environment variables from .env file
 87            jira_server = os.getenv('JIRA_SERVER', 'https://arusatech.atlassian.net')
 88            jira_user = os.getenv('JIRA_USER', 'yakub@arusatech.com')
 89            jira_api_key = os.getenv('JIRA_API_KEY', "")
 90            # Validate required environment variables
 91            if not jira_api_key or jira_api_key == '<JIRA_API_KEY>':
 92                raise ValueError("JIRA_API_KEY environment variable is required and must be set to a valid API key")
 93            self.client = JIRA(
 94                server=jira_server,
 95                basic_auth=(jira_user, jira_api_key)
 96            )
 97            logger.info("JIRA client initialized successfully")
 98        except Exception as e:
 99            logger.error(f"Failed to initialize JIRA client: {str(e)}")
100            logger.traceback(e)
101            raise
102
103    def create_issue(
104        self,
105        project_key: str,
106        summary: str,
107        description: str,
108        issue_type: str = None,
109        priority: str = None,
110        assignee: str = None,
111        labels: List[str] = None,
112        components: List[str] = None,
113        attachments: List[str] = None,
114        parent_issue_key: str = None,
115        linked_issues: List[Dict[str, str]] = None,
116        custom_fields: Dict[str, Any] = None
117    ) -> Optional[tuple[str, str]]:
118        """Create a new issue in JIRA with the specified details.
119        This method creates a new JIRA issue with the provided details and handles
120        optional features like attachments and issue linking.
121        Parameters
122        ----------
123        project_key : str
124            The key of the project where the issue should be created
125        summary : str
126            The summary/title of the issue
127        description : str
128            The detailed description of the issue
129        issue_type : str, optional
130            The type of issue (default: 'Bug')
131        priority : str, optional
132            The priority of the issue
133        assignee : str, optional
134            The username of the assignee
135        labels : List[str], optional
136            List of labels to add to the issue
137        components : List[str], optional
138            List of component names to add to the issue
139        attachments : List[str], optional
140            List of file paths to attach to the issue
141        linked_issues : List[Dict[str, str]], optional
142            List of issues to link to the new issue. Each dict should contain:
143            - 'key': The issue key to link to
144            - 'type': The type of link (default: 'Relates')
145        custom_fields : Dict[str, Any], optional
146            Dictionary of custom fields to set on the issue
147        Returns
148        -------
149        Optional[tuple[str, str]]
150            A tuple containing (issue_key, issue_id) if successful,
151            (None, None) if creation fails
152        Examples
153        --------
154        >>> handler = JiraHandler()
155        >>> result = handler.create_issue(
156        ...     project_key="PROJ",
157        ...     summary="Bug in login",
158        ...     description="User cannot login with valid credentials",
159        ...     issue_type="Bug",
160        ...     priority="High",
161        ...     labels=["login", "bug"],
162        ...     components=["Authentication"],
163        ...     attachments=["/path/to/screenshot.png"],
164        ...     linked_issues=[{"key": "PROJ-123", "type": "Blocks"}]
165        ... )
166        >>> print(f"Created issue {result[0]} with ID {result[1]}")
167        """
168        try:
169            # Build basic issue fields
170            issue_dict = {
171                'project': {'key': project_key},
172                'summary': summary,
173                'description': description,
174                'issuetype': {'name': issue_type or 'Bug'},
175                'parent': {'key': parent_issue_key} if parent_issue_key else None
176            }
177            # Add optional fields
178            if priority:
179                issue_dict['priority'] = {'name': priority}
180            if assignee:
181                issue_dict['assignee'] = {'name': assignee}
182            if labels:
183                issue_dict['labels'] = labels
184            if components:
185                issue_dict['components'] = [{'name': c} for c in components]
186            # Add any custom fields
187            if custom_fields:
188                issue_dict.update(custom_fields)
189            # Create the issue
190            issue = self.client.create_issue(fields=issue_dict)
191            logger.info(f"Created JIRA issue : {issue.key} [ID: {issue.id}]")
192            # Add attachments if provided
193            if attachments:
194                for file_path in attachments:
195                    if os.path.exists(file_path):
196                        self.client.add_attachment(
197                            issue=issue.key,
198                            attachment=file_path
199                        )
200                        logger.info(f"Added attachment: {file_path}")
201                    else:
202                        logger.warning(f"Attachment not found: {file_path}")
203            # Create issue links if provided
204            if linked_issues:
205                for link in linked_issues:
206                    try:
207                        self.client.create_issue_link(
208                            link.get('type', 'Relates'),
209                            issue.key,
210                            link['key']
211                        )
212                        logger.info(f"Created link between {issue.key} and {link['key']}")
213                    except Exception as e:
214                        logger.error(f"Failed to create link to {link['key']}: {str(e)}")
215            return (issue.key, issue.id)
216        except Exception as e:
217            logger.error(f"Failed to create JIRA issue for project {project_key}: {str(e)}")
218            logger.traceback(e)
219            return (None, None)
220
221    def get_issue(self, issue_key: str, fields: List[str] = None) -> Optional[Dict[str, Any]]:
222        """Get an issue by its key with specified fields.
223        This method retrieves a JIRA issue using its key and returns the issue details
224        with the specified fields. If no fields are specified, it returns all available fields.
225        Parameters
226        ----------
227        issue_key : str
228            The JIRA issue key to retrieve (e.g., "PROJ-123")
229        fields : List[str], optional
230            List of specific fields to retrieve. If None, all fields are returned.
231            Common fields include: "summary", "description", "status", "assignee", 
232            "reporter", "created", "updated", "priority", "labels", "components",
233            "attachments", "comments", "issuetype", "project"
234        Returns
235        -------
236        Optional[Dict[str, Any]]
237            A dictionary containing the issue details if successful, None if the issue
238            is not found or an error occurs.
239        Examples
240        --------
241        >>> handler = JiraHandler()
242        >>> # Get all fields
243        >>> issue = handler.get_issue("PROJ-123")
244        >>> print(f"Issue: {issue['summary']} - Status: {issue['status']['name']}")
245        >>> # Get specific fields only
246        >>> issue = handler.get_issue("PROJ-123", fields=["summary", "status", "assignee"])
247        >>> print(f"Assignee: {issue['assignee']['displayName'] if issue['assignee'] else 'Unassigned'}")
248        Notes
249        -----
250        - The issue key must be valid and accessible with current authentication
251        - If fields parameter is None, all fields are returned
252        - Some fields may be None if the issue doesn't have values for them
253        - Failed operations are logged as errors with relevant details
254        - The method handles missing issues gracefully by returning None
255        """
256        try:
257            if not issue_key:
258                logger.error("Issue key is required")
259                return None
260            # Define field mappings for JIRA API
261            field_mappings = {
262                'summary': 'summary',
263                'description': 'description', 
264                'status': 'status',
265                'assignee': 'assignee',
266                'reporter': 'reporter',
267                'priority': 'priority',
268                'labels': 'labels',
269                'components': 'components',
270                'issuetype': 'issuetype',
271                'project': 'project',
272                'created': 'created',
273                'updated': 'updated',
274                'resolutiondate': 'resolutiondate',
275                'duedate': 'duedate',
276                'attachments': 'attachments',
277                'comment': 'comment',
278                'issuelinks': 'issuelinks'
279            }
280            # Determine requested fields
281            if fields is None:
282                requested_fields = None
283            else:
284                # Map requested fields to JIRA field names
285                jira_fields = [field_mappings.get(field, field) for field in fields]
286                # Always include key and id as they're required
287                if 'key' not in fields:
288                    jira_fields.append('key')
289                if 'id' not in fields:
290                    jira_fields.append('id')
291                requested_fields = ','.join(jira_fields)
292            # Get the issue using the JIRA client
293            issue = self.client.issue(issue_key, fields=requested_fields)
294            if not issue:
295                logger.warning(f"Issue not found: {issue_key}")
296                return None
297            # Helper function to safely get user attributes
298            def get_user_dict(user_obj):
299                if not user_obj:
300                    return None
301                try:
302                    return {
303                        'name': getattr(user_obj, 'name', None),
304                        'displayName': getattr(user_obj, 'displayName', None),
305                        'emailAddress': getattr(user_obj, 'emailAddress', None)
306                    }
307                except Exception:
308                    return None
309            # Helper function to safely get field value
310            def safe_get_field(field_name, default=None):
311                try:
312                    return getattr(issue.fields, field_name, default)
313                except AttributeError:
314                    return default
315            # Helper function to get object attributes safely
316            def get_object_attrs(obj, attrs):
317                if not obj:
318                    return None
319                return {attr: getattr(obj, attr, None) for attr in attrs}
320            # Helper function to process attachments
321            def process_attachments(attachments):
322                if not attachments:
323                    return []
324                return [
325                    {
326                        'id': getattr(att, 'id', None),
327                        'filename': getattr(att, 'filename', None),
328                        'size': getattr(att, 'size', None),
329                        'created': getattr(att, 'created', None),
330                        'mimeType': getattr(att, 'mimeType', None)
331                    } for att in attachments
332                ]
333            # Helper function to process comments
334            def process_comments(comments):
335                if not comments or not hasattr(comments, 'comments'):
336                    return []
337                return [
338                    {
339                        'id': getattr(comment, 'id', None),
340                        'body': getattr(comment, 'body', None),
341                        'author': get_user_dict(comment.author),
342                        'created': getattr(comment, 'created', None),
343                        'updated': getattr(comment, 'updated', None)
344                    } for comment in comments.comments
345                ]
346            # Helper function to process issue links
347            def process_issue_links(issue_links):
348                if not issue_links:
349                    return []
350                def process_issue_reference(issue_ref, direction):
351                    if not hasattr(issue_ref, direction) or not getattr(issue_ref, direction):
352                        return None
353                    ref_issue = getattr(issue_ref, direction)
354                    return {
355                        'key': getattr(ref_issue, 'key', None),
356                        'id': getattr(ref_issue, 'id', None),
357                        'fields': {
358                            'summary': getattr(ref_issue.fields, 'summary', None),
359                            'status': get_object_attrs(ref_issue.fields.status, ['name']) if ref_issue.fields.status else None
360                        }
361                    }
362                return [
363                    {
364                        'id': getattr(link, 'id', None),
365                        'type': get_object_attrs(link.type, ['id', 'name', 'inward', 'outward']) if link.type else None,
366                        'inwardIssue': process_issue_reference(link, 'inwardIssue'),
367                        'outwardIssue': process_issue_reference(link, 'outwardIssue')
368                    } for link in issue_links
369                ]
370            # Build response dictionary
371            issue_dict = {
372                'key': issue.key,
373                'id': issue.id
374            }
375            # Determine which fields to process
376            fields_to_process = fields if fields is not None else list(field_mappings.keys())
377            # Process each field
378            for field in fields_to_process:
379                if field in ['key', 'id']:
380                    continue  # Already handled
381                field_value = safe_get_field(field_mappings.get(field, field))
382                match field:
383                    case 'summary' | 'description' | 'created' | 'updated' | 'resolutiondate' | 'duedate':
384                        issue_dict[field] = field_value
385                    case 'status' | 'issuetype' | 'priority':
386                        issue_dict[field] = get_object_attrs(field_value, ['id', 'name', 'description'])
387                    case 'project':
388                        issue_dict[field] = get_object_attrs(field_value, ['key', 'name', 'id'])
389                    case 'assignee' | 'reporter':
390                        issue_dict[field] = get_user_dict(field_value)
391                    case 'labels':
392                        issue_dict[field] = list(field_value) if field_value else []
393                    case 'components':
394                        issue_dict[field] = [
395                            get_object_attrs(comp, ['id', 'name', 'description']) 
396                            for comp in (field_value or [])
397                        ]
398                    case 'attachments':
399                        issue_dict[field] = process_attachments(field_value)
400                    case 'comments':
401                        issue_dict[field] = process_comments(field_value)
402                    case 'issuelinks':
403                        issue_dict[field] = process_issue_links(field_value)
404                    case _:
405                        # Handle unknown fields or custom fields
406                        issue_dict[field] = field_value
407            # logger.info(f"Retrieved JIRA issue: {issue_key}")
408            return issue_dict
409        except Exception as e:
410            logger.error(f"Failed to get JIRA issue {issue_key}: {str(e)}")
411            logger.traceback(e)
412            return None
413
414    def update_issue_summary(self, issue_key: str, new_summary: str) -> bool:
415        """Update the summary of a JIRA issue.
416        This method updates the summary field of an existing JIRA issue using the JIRA REST API.
417        It validates the input parameters and handles errors gracefully with comprehensive logging.
418        Parameters
419        ----------
420        issue_key : str
421            The JIRA issue key to update (e.g., "PROJ-123")
422        new_summary : str
423            The new summary text to set for the issue
424        Returns
425        -------
426        bool
427            True if the summary was successfully updated, False if the operation failed.
428            Returns None if an error occurs during the API request.
429        Examples
430        --------
431        >>> handler = JiraHandler()
432        >>> success = handler.update_issue_summary("PROJ-123", "Updated bug description")
433        >>> print(success)
434        True
435        Notes
436        -----
437        - The issue key must be valid and accessible with current authentication
438        - The new summary cannot be empty or None
439        - Failed operations are logged as errors with relevant details
440        - The method uses the JIRA client's update method for efficient API calls
441        """
442        try:
443            # Validate input parameters
444            if not issue_key or not issue_key.strip():
445                logger.error("Issue key is required and cannot be empty")
446                return False
447            if not new_summary or not new_summary.strip():
448                logger.error("New summary is required and cannot be empty")
449                return False
450            # Strip whitespace from inputs
451            issue_key = issue_key.strip()
452            new_summary = new_summary.strip()
453            # logger.info(f"Updating summary for issue {issue_key}")
454            # Get the issue object
455            issue = self.client.issue(issue_key)
456            if not issue:
457                logger.error(f"Issue not found: {issue_key}")
458                return False
459            # Update the summary field
460            issue.update(summary=new_summary)
461            logger.info(f"Successfully updated summary for issue {issue_key}")
462            return True
463        except Exception as e:
464            logger.error(f"Failed to update summary for issue {issue_key}: {str(e)}")
465            logger.traceback(e)
466            return False
467
468    def _build_auth_headers(self, api_key: str = None, user: str = None, cookie: str = None) -> Dict[str, str]:
469        """
470        Build authentication headers for JIRA API requests.
471        
472        This method converts an API key to base64 format and creates the proper
473        Authorization header, similar to how Postman generates it.
474        
475        Parameters
476        ----------
477        api_key : str, optional
478            The JIRA API key. If not provided, uses the one from environment variables.
479        user : str, optional
480            The JIRA user email. If not provided, uses the one from environment variables.
481        cookie : str, optional
482            Additional cookie value to include in headers.
483            
484        Returns
485        -------
486        Dict[str, str]
487            Dictionary containing the Authorization and Cookie headers.
488            
489        Examples
490        --------
491        >>> handler = JiraHandler()
492        >>> headers = handler._build_auth_headers()
493        >>> print(headers)
494        {
495            'Authorization': 'Basic eWFrdWIubW9oYW1tYWRAd25jby5jb206QVRBVFQzeEZmR0YwN29tcFRCcU9FVUxlXzJjWlFDbkJXb2ZTYS1xMW92YmYxYnBURC1URmppY3VFczVBUzFJMkdjaXcybHlNMEFaRjl1T19OSU0yR0tIMlZ6SkQtQ0JtLTV2T05RNHhnMEFKbzVoaWhtQjIxaHc3Zk54MUFicjFtTWx1R0M4cVJoVDIzUkZlQUlaMVk3UUd0UnBLQlFLOV9iV0hyWnhPOWlucURRVjh4ZC0wd2tNPTIyQTdDMjg1',
496            'Cookie': 'atlassian.xsrf.token=9dd7b0ae95b82b138b9fd93e27a45a6fd01c548e_lin'
497        }
498        """
499        try:
500            # Use provided values or fall back to environment variables
501            api_key = api_key or os.getenv('JIRA_API_KEY')
502            user = user or os.getenv('JIRA_USER', 'yakub@arusatech.com')
503            
504            if not api_key:
505                raise ValueError("API key is required")
506            if not user:
507                raise ValueError("User email is required")
508            
509            # Create the credentials string in format "user:api_key"
510            credentials = f"{user}:{api_key}"
511            
512            # Encode to base64
513            encoded_credentials = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
514            
515            # Build headers
516            headers = {
517                'Authorization': f'Basic {encoded_credentials}'
518            }
519            
520            # Add cookie if provided
521            if cookie:
522                headers['Cookie'] = cookie
523                
524            return headers
525            
526        except Exception as e:
527            logger.error(f"Failed to build auth headers: {str(e)}")
528            logger.traceback(e)
529            raise
530
531    def make_jira_request(self, jira_key: str, url: str, method: str = "GET", payload: Dict = None, 
532                         api_key: str = None, user: str = None, cookie: str = None) -> Optional[Dict]:
533        """
534        Make a JIRA API request with proper authentication headers.
535        
536        This method builds the authentication headers (similar to Postman) and
537        makes the request to the JIRA API.
538        
539        Parameters
540        ----------
541        jira_key : str
542            The JIRA issue key
543        method : str, optional
544            HTTP method (GET, POST, PUT, DELETE). Defaults to "GET"
545        payload : Dict, optional
546            Request payload for POST/PUT requests
547        api_key : str, optional
548            The JIRA API key. If not provided, uses environment variable
549        user : str, optional
550            The JIRA user email. If not provided, uses environment variable
551        cookie : str, optional
552            Additional cookie value
553            
554        Returns
555        -------
556        Optional[Dict]
557            The JSON response from the API, or None if the request fails
558            
559        Examples
560        --------
561        >>> handler = JiraHandler()
562        >>> response = handler.make_jira_request(
563        ...     url="https://arusa.atlassian.net/rest/api/2/issue/XSP1-543213456"
564        ... )
565        >>> print(response)
566        {'id': '12345', 'key': 'XSP1-3456', ...}
567        """
568        try:
569            if not jira_key:
570                logger.error("JIRA issue key is required")
571                return None
572            
573            url = f"{os.getenv('JIRA_SERVER')}/rest/api/2/issue/{jira_key}"
574            if not url:
575                logger.error("JIRA API endpoint URL is required")
576                return None
577            # Build authentication headers
578            headers = self._build_auth_headers(api_key, user, cookie)
579            
580            # Make the request
581            response = requests.request(method, url, headers=headers, data=payload)
582            response.raise_for_status()
583            
584            # Return JSON response
585            return response.json()
586            
587        except requests.exceptions.RequestException as e:
588            logger.error(f"JIRA API request failed: {str(e)}")
589            logger.traceback(e)
590            return None
591        except Exception as e:
592            logger.error(f"Unexpected error in JIRA request: {str(e)}")
593            logger.traceback(e)
594            return None
595
596    def download_jira_attachment_by_id(self, attachment_id: str, mime_type: str) -> Optional[Dict]:
597        '''
598        Download a JIRA attachment by its ID.
599        '''
600        try:
601            # ATTACHMENT_URL = f"{os.getenv('JIRA_SERVER')}/rest/api/2/attachment/{attachment_id}"
602            CONTENT_URL = f"{os.getenv('JIRA_SERVER')}/rest/api/2/attachment/content/{attachment_id}"
603            if not CONTENT_URL:
604                logger.error(f"No content URL found for attachment '{attachment_id}'")
605                return None
606            headers = self._build_auth_headers()
607            download_response = requests.get(CONTENT_URL, headers=headers)
608            download_response.raise_for_status()
609            content = download_response.content
610            #Process content based on type
611            result = {
612                'content': content,
613                'mime_type': mime_type,
614                'text_content': None,
615                'json_content': None
616            }
617            
618            # Handle text-based files
619            if mime_type.startswith(('text/', 'application/json', 'application/xml' , 'json')):
620                try:
621                    text_content = content.decode('utf-8')
622                    result['text_content'] = text_content
623                    
624                    # Try to parse as JSON
625                    if mime_type == 'application/json':
626                        try:
627                            result['json_content'] = json.loads(text_content)
628                        except json.JSONDecodeError:
629                            pass
630                except UnicodeDecodeError:
631                    logger.error(f"Warning: Could not decode text content for {attachment_id}")
632                    logger.traceback(e)
633            
634            return result
635        except Exception as e:
636            logger.error(f"Error downloading JIRA attachment: {str(e)}")
637            logger.traceback(e)
638            return None

A handler class for interacting with JIRA's REST API. This class provides methods to interact with JIRA's REST API, handling authentication, issue creation, retrieval, and various JIRA operations. It uses environment variables for configuration and provides robust error handling with comprehensive logging. The class supports creating issues with attachments, linking issues, and retrieving detailed issue information with customizable field selection. It handles various JIRA field types including user information, attachments, comments, and issue links.

Attributes

client : JIRA The JIRA client instance used for API interactions

Environment Variables

JIRA_SERVER : str The JIRA server URL (default: 'https://arusa.atlassian.net') JIRA_USER : str The JIRA user email (default: 'yakub@arusatech.com') JIRA_API_KEY : str The JIRA API key for authentication (required)

Methods

create_issue(project_key, summary, description, **kwargs) Create a new JIRA issue with optional attachments, linking, and custom fields get_issue(issue_key, fields=None) Retrieve a JIRA issue with specified fields or all available fields

Examples

>>> handler = JiraHandler()
>>> # Create a new issue
>>> issue_key, issue_id = handler.create_issue(
...     project_key="PROJ",
...     summary="New feature implementation",
...     description="Implement new login flow",
...     issue_type="Story",
...     priority="High",
...     labels=["feature", "login"],
...     attachments=["/path/to/screenshot.png"]
... )
>>> print(f"Created issue {issue_key} with ID {issue_id}")
>>> # Retrieve issue details
>>> issue = handler.get_issue("PROJ-123", fields=["summary", "status", "assignee"])
>>> print(f"Issue: {issue['summary']} - Status: {issue['status']['name']}")
<h2 id="notes">Notes</h2>
  • Requires valid JIRA credentials stored in environment variables
  • Automatically loads configuration from .env file if present
  • Provides comprehensive error handling and logging
  • Supports various JIRA field types and custom fields
  • Handles file attachments with automatic MIME type detection
  • Creates issue links with configurable link types
  • Returns None for failed operations instead of raising exceptions
JiraHandler()
 67    def __init__(self):
 68        """Initialize the JIRA client with configuration from environment variables.
 69        This constructor sets up the JIRA client by reading configuration from
 70        environment variables. It automatically loads variables from a .env file
 71        if present in the project root.
 72        Environment Variables
 73        -------------------
 74        JIRA_SERVER : str
 75            The JIRA server URL (default: 'https://arusatech.atlassian.net')
 76        JIRA_USER : str
 77            The JIRA user email (default: 'yakub@arusatech.com')
 78        JIRA_API_KEY : str
 79            The JIRA API key for authentication
 80        Raises
 81        ------
 82        Exception
 83            If the JIRA client initialization fails
 84        """
 85        try:
 86            # Load environment variables from .env file
 87            jira_server = os.getenv('JIRA_SERVER', 'https://arusatech.atlassian.net')
 88            jira_user = os.getenv('JIRA_USER', 'yakub@arusatech.com')
 89            jira_api_key = os.getenv('JIRA_API_KEY', "")
 90            # Validate required environment variables
 91            if not jira_api_key or jira_api_key == '<JIRA_API_KEY>':
 92                raise ValueError("JIRA_API_KEY environment variable is required and must be set to a valid API key")
 93            self.client = JIRA(
 94                server=jira_server,
 95                basic_auth=(jira_user, jira_api_key)
 96            )
 97            logger.info("JIRA client initialized successfully")
 98        except Exception as e:
 99            logger.error(f"Failed to initialize JIRA client: {str(e)}")
100            logger.traceback(e)
101            raise

Initialize the JIRA client with configuration from environment variables. This constructor sets up the JIRA client by reading configuration from environment variables. It automatically loads variables from a .env file if present in the project root.

Environment Variables

JIRA_SERVER : str The JIRA server URL (default: 'https://arusatech.atlassian.net') JIRA_USER : str The JIRA user email (default: 'yakub@arusatech.com') JIRA_API_KEY : str The JIRA API key for authentication

Raises

Exception If the JIRA client initialization fails

def create_issue( self, project_key: str, summary: str, description: str, issue_type: str = None, priority: str = None, assignee: str = None, labels: List[str] = None, components: List[str] = None, attachments: List[str] = None, parent_issue_key: str = None, linked_issues: List[Dict[str, str]] = None, custom_fields: Dict[str, Any] = None) -> Optional[tuple[str, str]]:
103    def create_issue(
104        self,
105        project_key: str,
106        summary: str,
107        description: str,
108        issue_type: str = None,
109        priority: str = None,
110        assignee: str = None,
111        labels: List[str] = None,
112        components: List[str] = None,
113        attachments: List[str] = None,
114        parent_issue_key: str = None,
115        linked_issues: List[Dict[str, str]] = None,
116        custom_fields: Dict[str, Any] = None
117    ) -> Optional[tuple[str, str]]:
118        """Create a new issue in JIRA with the specified details.
119        This method creates a new JIRA issue with the provided details and handles
120        optional features like attachments and issue linking.
121        Parameters
122        ----------
123        project_key : str
124            The key of the project where the issue should be created
125        summary : str
126            The summary/title of the issue
127        description : str
128            The detailed description of the issue
129        issue_type : str, optional
130            The type of issue (default: 'Bug')
131        priority : str, optional
132            The priority of the issue
133        assignee : str, optional
134            The username of the assignee
135        labels : List[str], optional
136            List of labels to add to the issue
137        components : List[str], optional
138            List of component names to add to the issue
139        attachments : List[str], optional
140            List of file paths to attach to the issue
141        linked_issues : List[Dict[str, str]], optional
142            List of issues to link to the new issue. Each dict should contain:
143            - 'key': The issue key to link to
144            - 'type': The type of link (default: 'Relates')
145        custom_fields : Dict[str, Any], optional
146            Dictionary of custom fields to set on the issue
147        Returns
148        -------
149        Optional[tuple[str, str]]
150            A tuple containing (issue_key, issue_id) if successful,
151            (None, None) if creation fails
152        Examples
153        --------
154        >>> handler = JiraHandler()
155        >>> result = handler.create_issue(
156        ...     project_key="PROJ",
157        ...     summary="Bug in login",
158        ...     description="User cannot login with valid credentials",
159        ...     issue_type="Bug",
160        ...     priority="High",
161        ...     labels=["login", "bug"],
162        ...     components=["Authentication"],
163        ...     attachments=["/path/to/screenshot.png"],
164        ...     linked_issues=[{"key": "PROJ-123", "type": "Blocks"}]
165        ... )
166        >>> print(f"Created issue {result[0]} with ID {result[1]}")
167        """
168        try:
169            # Build basic issue fields
170            issue_dict = {
171                'project': {'key': project_key},
172                'summary': summary,
173                'description': description,
174                'issuetype': {'name': issue_type or 'Bug'},
175                'parent': {'key': parent_issue_key} if parent_issue_key else None
176            }
177            # Add optional fields
178            if priority:
179                issue_dict['priority'] = {'name': priority}
180            if assignee:
181                issue_dict['assignee'] = {'name': assignee}
182            if labels:
183                issue_dict['labels'] = labels
184            if components:
185                issue_dict['components'] = [{'name': c} for c in components]
186            # Add any custom fields
187            if custom_fields:
188                issue_dict.update(custom_fields)
189            # Create the issue
190            issue = self.client.create_issue(fields=issue_dict)
191            logger.info(f"Created JIRA issue : {issue.key} [ID: {issue.id}]")
192            # Add attachments if provided
193            if attachments:
194                for file_path in attachments:
195                    if os.path.exists(file_path):
196                        self.client.add_attachment(
197                            issue=issue.key,
198                            attachment=file_path
199                        )
200                        logger.info(f"Added attachment: {file_path}")
201                    else:
202                        logger.warning(f"Attachment not found: {file_path}")
203            # Create issue links if provided
204            if linked_issues:
205                for link in linked_issues:
206                    try:
207                        self.client.create_issue_link(
208                            link.get('type', 'Relates'),
209                            issue.key,
210                            link['key']
211                        )
212                        logger.info(f"Created link between {issue.key} and {link['key']}")
213                    except Exception as e:
214                        logger.error(f"Failed to create link to {link['key']}: {str(e)}")
215            return (issue.key, issue.id)
216        except Exception as e:
217            logger.error(f"Failed to create JIRA issue for project {project_key}: {str(e)}")
218            logger.traceback(e)
219            return (None, None)

Create a new issue in JIRA with the specified details. This method creates a new JIRA issue with the provided details and handles optional features like attachments and issue linking.

Parameters

project_key : str The key of the project where the issue should be created summary : str The summary/title of the issue description : str The detailed description of the issue issue_type : str, optional The type of issue (default: 'Bug') priority : str, optional The priority of the issue assignee : str, optional The username of the assignee labels : List[str], optional List of labels to add to the issue components : List[str], optional List of component names to add to the issue attachments : List[str], optional List of file paths to attach to the issue linked_issues : List[Dict[str, str]], optional List of issues to link to the new issue. Each dict should contain: - 'key': The issue key to link to - 'type': The type of link (default: 'Relates') custom_fields : Dict[str, Any], optional Dictionary of custom fields to set on the issue

Returns

Optional[tuple[str, str]] A tuple containing (issue_key, issue_id) if successful, (None, None) if creation fails

Examples

>>> handler = JiraHandler()
>>> result = handler.create_issue(
...     project_key="PROJ",
...     summary="Bug in login",
...     description="User cannot login with valid credentials",
...     issue_type="Bug",
...     priority="High",
...     labels=["login", "bug"],
...     components=["Authentication"],
...     attachments=["/path/to/screenshot.png"],
...     linked_issues=[{"key": "PROJ-123", "type": "Blocks"}]
... )
>>> print(f"Created issue {result[0]} with ID {result[1]}")
def get_issue( self, issue_key: str, fields: List[str] = None) -> Optional[Dict[str, Any]]:
221    def get_issue(self, issue_key: str, fields: List[str] = None) -> Optional[Dict[str, Any]]:
222        """Get an issue by its key with specified fields.
223        This method retrieves a JIRA issue using its key and returns the issue details
224        with the specified fields. If no fields are specified, it returns all available fields.
225        Parameters
226        ----------
227        issue_key : str
228            The JIRA issue key to retrieve (e.g., "PROJ-123")
229        fields : List[str], optional
230            List of specific fields to retrieve. If None, all fields are returned.
231            Common fields include: "summary", "description", "status", "assignee", 
232            "reporter", "created", "updated", "priority", "labels", "components",
233            "attachments", "comments", "issuetype", "project"
234        Returns
235        -------
236        Optional[Dict[str, Any]]
237            A dictionary containing the issue details if successful, None if the issue
238            is not found or an error occurs.
239        Examples
240        --------
241        >>> handler = JiraHandler()
242        >>> # Get all fields
243        >>> issue = handler.get_issue("PROJ-123")
244        >>> print(f"Issue: {issue['summary']} - Status: {issue['status']['name']}")
245        >>> # Get specific fields only
246        >>> issue = handler.get_issue("PROJ-123", fields=["summary", "status", "assignee"])
247        >>> print(f"Assignee: {issue['assignee']['displayName'] if issue['assignee'] else 'Unassigned'}")
248        Notes
249        -----
250        - The issue key must be valid and accessible with current authentication
251        - If fields parameter is None, all fields are returned
252        - Some fields may be None if the issue doesn't have values for them
253        - Failed operations are logged as errors with relevant details
254        - The method handles missing issues gracefully by returning None
255        """
256        try:
257            if not issue_key:
258                logger.error("Issue key is required")
259                return None
260            # Define field mappings for JIRA API
261            field_mappings = {
262                'summary': 'summary',
263                'description': 'description', 
264                'status': 'status',
265                'assignee': 'assignee',
266                'reporter': 'reporter',
267                'priority': 'priority',
268                'labels': 'labels',
269                'components': 'components',
270                'issuetype': 'issuetype',
271                'project': 'project',
272                'created': 'created',
273                'updated': 'updated',
274                'resolutiondate': 'resolutiondate',
275                'duedate': 'duedate',
276                'attachments': 'attachments',
277                'comment': 'comment',
278                'issuelinks': 'issuelinks'
279            }
280            # Determine requested fields
281            if fields is None:
282                requested_fields = None
283            else:
284                # Map requested fields to JIRA field names
285                jira_fields = [field_mappings.get(field, field) for field in fields]
286                # Always include key and id as they're required
287                if 'key' not in fields:
288                    jira_fields.append('key')
289                if 'id' not in fields:
290                    jira_fields.append('id')
291                requested_fields = ','.join(jira_fields)
292            # Get the issue using the JIRA client
293            issue = self.client.issue(issue_key, fields=requested_fields)
294            if not issue:
295                logger.warning(f"Issue not found: {issue_key}")
296                return None
297            # Helper function to safely get user attributes
298            def get_user_dict(user_obj):
299                if not user_obj:
300                    return None
301                try:
302                    return {
303                        'name': getattr(user_obj, 'name', None),
304                        'displayName': getattr(user_obj, 'displayName', None),
305                        'emailAddress': getattr(user_obj, 'emailAddress', None)
306                    }
307                except Exception:
308                    return None
309            # Helper function to safely get field value
310            def safe_get_field(field_name, default=None):
311                try:
312                    return getattr(issue.fields, field_name, default)
313                except AttributeError:
314                    return default
315            # Helper function to get object attributes safely
316            def get_object_attrs(obj, attrs):
317                if not obj:
318                    return None
319                return {attr: getattr(obj, attr, None) for attr in attrs}
320            # Helper function to process attachments
321            def process_attachments(attachments):
322                if not attachments:
323                    return []
324                return [
325                    {
326                        'id': getattr(att, 'id', None),
327                        'filename': getattr(att, 'filename', None),
328                        'size': getattr(att, 'size', None),
329                        'created': getattr(att, 'created', None),
330                        'mimeType': getattr(att, 'mimeType', None)
331                    } for att in attachments
332                ]
333            # Helper function to process comments
334            def process_comments(comments):
335                if not comments or not hasattr(comments, 'comments'):
336                    return []
337                return [
338                    {
339                        'id': getattr(comment, 'id', None),
340                        'body': getattr(comment, 'body', None),
341                        'author': get_user_dict(comment.author),
342                        'created': getattr(comment, 'created', None),
343                        'updated': getattr(comment, 'updated', None)
344                    } for comment in comments.comments
345                ]
346            # Helper function to process issue links
347            def process_issue_links(issue_links):
348                if not issue_links:
349                    return []
350                def process_issue_reference(issue_ref, direction):
351                    if not hasattr(issue_ref, direction) or not getattr(issue_ref, direction):
352                        return None
353                    ref_issue = getattr(issue_ref, direction)
354                    return {
355                        'key': getattr(ref_issue, 'key', None),
356                        'id': getattr(ref_issue, 'id', None),
357                        'fields': {
358                            'summary': getattr(ref_issue.fields, 'summary', None),
359                            'status': get_object_attrs(ref_issue.fields.status, ['name']) if ref_issue.fields.status else None
360                        }
361                    }
362                return [
363                    {
364                        'id': getattr(link, 'id', None),
365                        'type': get_object_attrs(link.type, ['id', 'name', 'inward', 'outward']) if link.type else None,
366                        'inwardIssue': process_issue_reference(link, 'inwardIssue'),
367                        'outwardIssue': process_issue_reference(link, 'outwardIssue')
368                    } for link in issue_links
369                ]
370            # Build response dictionary
371            issue_dict = {
372                'key': issue.key,
373                'id': issue.id
374            }
375            # Determine which fields to process
376            fields_to_process = fields if fields is not None else list(field_mappings.keys())
377            # Process each field
378            for field in fields_to_process:
379                if field in ['key', 'id']:
380                    continue  # Already handled
381                field_value = safe_get_field(field_mappings.get(field, field))
382                match field:
383                    case 'summary' | 'description' | 'created' | 'updated' | 'resolutiondate' | 'duedate':
384                        issue_dict[field] = field_value
385                    case 'status' | 'issuetype' | 'priority':
386                        issue_dict[field] = get_object_attrs(field_value, ['id', 'name', 'description'])
387                    case 'project':
388                        issue_dict[field] = get_object_attrs(field_value, ['key', 'name', 'id'])
389                    case 'assignee' | 'reporter':
390                        issue_dict[field] = get_user_dict(field_value)
391                    case 'labels':
392                        issue_dict[field] = list(field_value) if field_value else []
393                    case 'components':
394                        issue_dict[field] = [
395                            get_object_attrs(comp, ['id', 'name', 'description']) 
396                            for comp in (field_value or [])
397                        ]
398                    case 'attachments':
399                        issue_dict[field] = process_attachments(field_value)
400                    case 'comments':
401                        issue_dict[field] = process_comments(field_value)
402                    case 'issuelinks':
403                        issue_dict[field] = process_issue_links(field_value)
404                    case _:
405                        # Handle unknown fields or custom fields
406                        issue_dict[field] = field_value
407            # logger.info(f"Retrieved JIRA issue: {issue_key}")
408            return issue_dict
409        except Exception as e:
410            logger.error(f"Failed to get JIRA issue {issue_key}: {str(e)}")
411            logger.traceback(e)
412            return None

Get an issue by its key with specified fields. This method retrieves a JIRA issue using its key and returns the issue details with the specified fields. If no fields are specified, it returns all available fields.

Parameters

issue_key : str The JIRA issue key to retrieve (e.g., "PROJ-123") fields : List[str], optional List of specific fields to retrieve. If None, all fields are returned. Common fields include: "summary", "description", "status", "assignee", "reporter", "created", "updated", "priority", "labels", "components", "attachments", "comments", "issuetype", "project"

Returns

Optional[Dict[str, Any]] A dictionary containing the issue details if successful, None if the issue is not found or an error occurs.

Examples

>>> handler = JiraHandler()
>>> # Get all fields
>>> issue = handler.get_issue("PROJ-123")
>>> print(f"Issue: {issue['summary']} - Status: {issue['status']['name']}")
>>> # Get specific fields only
>>> issue = handler.get_issue("PROJ-123", fields=["summary", "status", "assignee"])
>>> print(f"Assignee: {issue['assignee']['displayName'] if issue['assignee'] else 'Unassigned'}")
<h2 id="notes">Notes</h2>
  • The issue key must be valid and accessible with current authentication
  • If fields parameter is None, all fields are returned
  • Some fields may be None if the issue doesn't have values for them
  • Failed operations are logged as errors with relevant details
  • The method handles missing issues gracefully by returning None
def update_issue_summary(self, issue_key: str, new_summary: str) -> bool:
414    def update_issue_summary(self, issue_key: str, new_summary: str) -> bool:
415        """Update the summary of a JIRA issue.
416        This method updates the summary field of an existing JIRA issue using the JIRA REST API.
417        It validates the input parameters and handles errors gracefully with comprehensive logging.
418        Parameters
419        ----------
420        issue_key : str
421            The JIRA issue key to update (e.g., "PROJ-123")
422        new_summary : str
423            The new summary text to set for the issue
424        Returns
425        -------
426        bool
427            True if the summary was successfully updated, False if the operation failed.
428            Returns None if an error occurs during the API request.
429        Examples
430        --------
431        >>> handler = JiraHandler()
432        >>> success = handler.update_issue_summary("PROJ-123", "Updated bug description")
433        >>> print(success)
434        True
435        Notes
436        -----
437        - The issue key must be valid and accessible with current authentication
438        - The new summary cannot be empty or None
439        - Failed operations are logged as errors with relevant details
440        - The method uses the JIRA client's update method for efficient API calls
441        """
442        try:
443            # Validate input parameters
444            if not issue_key or not issue_key.strip():
445                logger.error("Issue key is required and cannot be empty")
446                return False
447            if not new_summary or not new_summary.strip():
448                logger.error("New summary is required and cannot be empty")
449                return False
450            # Strip whitespace from inputs
451            issue_key = issue_key.strip()
452            new_summary = new_summary.strip()
453            # logger.info(f"Updating summary for issue {issue_key}")
454            # Get the issue object
455            issue = self.client.issue(issue_key)
456            if not issue:
457                logger.error(f"Issue not found: {issue_key}")
458                return False
459            # Update the summary field
460            issue.update(summary=new_summary)
461            logger.info(f"Successfully updated summary for issue {issue_key}")
462            return True
463        except Exception as e:
464            logger.error(f"Failed to update summary for issue {issue_key}: {str(e)}")
465            logger.traceback(e)
466            return False

Update the summary of a JIRA issue. This method updates the summary field of an existing JIRA issue using the JIRA REST API. It validates the input parameters and handles errors gracefully with comprehensive logging.

Parameters

issue_key : str The JIRA issue key to update (e.g., "PROJ-123") new_summary : str The new summary text to set for the issue

Returns

bool True if the summary was successfully updated, False if the operation failed. Returns None if an error occurs during the API request.

Examples

>>> handler = JiraHandler()
>>> success = handler.update_issue_summary("PROJ-123", "Updated bug description")
>>> print(success)
True
<h2 id="notes">Notes</h2>
  • The issue key must be valid and accessible with current authentication
  • The new summary cannot be empty or None
  • Failed operations are logged as errors with relevant details
  • The method uses the JIRA client's update method for efficient API calls
def make_jira_request( self, jira_key: str, url: str, method: str = 'GET', payload: Dict = None, api_key: str = None, user: str = None, cookie: str = None) -> Optional[Dict]:
531    def make_jira_request(self, jira_key: str, url: str, method: str = "GET", payload: Dict = None, 
532                         api_key: str = None, user: str = None, cookie: str = None) -> Optional[Dict]:
533        """
534        Make a JIRA API request with proper authentication headers.
535        
536        This method builds the authentication headers (similar to Postman) and
537        makes the request to the JIRA API.
538        
539        Parameters
540        ----------
541        jira_key : str
542            The JIRA issue key
543        method : str, optional
544            HTTP method (GET, POST, PUT, DELETE). Defaults to "GET"
545        payload : Dict, optional
546            Request payload for POST/PUT requests
547        api_key : str, optional
548            The JIRA API key. If not provided, uses environment variable
549        user : str, optional
550            The JIRA user email. If not provided, uses environment variable
551        cookie : str, optional
552            Additional cookie value
553            
554        Returns
555        -------
556        Optional[Dict]
557            The JSON response from the API, or None if the request fails
558            
559        Examples
560        --------
561        >>> handler = JiraHandler()
562        >>> response = handler.make_jira_request(
563        ...     url="https://arusa.atlassian.net/rest/api/2/issue/XSP1-543213456"
564        ... )
565        >>> print(response)
566        {'id': '12345', 'key': 'XSP1-3456', ...}
567        """
568        try:
569            if not jira_key:
570                logger.error("JIRA issue key is required")
571                return None
572            
573            url = f"{os.getenv('JIRA_SERVER')}/rest/api/2/issue/{jira_key}"
574            if not url:
575                logger.error("JIRA API endpoint URL is required")
576                return None
577            # Build authentication headers
578            headers = self._build_auth_headers(api_key, user, cookie)
579            
580            # Make the request
581            response = requests.request(method, url, headers=headers, data=payload)
582            response.raise_for_status()
583            
584            # Return JSON response
585            return response.json()
586            
587        except requests.exceptions.RequestException as e:
588            logger.error(f"JIRA API request failed: {str(e)}")
589            logger.traceback(e)
590            return None
591        except Exception as e:
592            logger.error(f"Unexpected error in JIRA request: {str(e)}")
593            logger.traceback(e)
594            return None

Make a JIRA API request with proper authentication headers.

This method builds the authentication headers (similar to Postman) and makes the request to the JIRA API.

Parameters

jira_key : str The JIRA issue key method : str, optional HTTP method (GET, POST, PUT, DELETE). Defaults to "GET" payload : Dict, optional Request payload for POST/PUT requests api_key : str, optional The JIRA API key. If not provided, uses environment variable user : str, optional The JIRA user email. If not provided, uses environment variable cookie : str, optional Additional cookie value

Returns

Optional[Dict] The JSON response from the API, or None if the request fails

Examples

>>> handler = JiraHandler()
>>> response = handler.make_jira_request(
...     url="https://arusa.atlassian.net/rest/api/2/issue/XSP1-543213456"
... )
>>> print(response)
{'id': '12345', 'key': 'XSP1-3456', ...}
def download_jira_attachment_by_id(self, attachment_id: str, mime_type: str) -> Optional[Dict]:
596    def download_jira_attachment_by_id(self, attachment_id: str, mime_type: str) -> Optional[Dict]:
597        '''
598        Download a JIRA attachment by its ID.
599        '''
600        try:
601            # ATTACHMENT_URL = f"{os.getenv('JIRA_SERVER')}/rest/api/2/attachment/{attachment_id}"
602            CONTENT_URL = f"{os.getenv('JIRA_SERVER')}/rest/api/2/attachment/content/{attachment_id}"
603            if not CONTENT_URL:
604                logger.error(f"No content URL found for attachment '{attachment_id}'")
605                return None
606            headers = self._build_auth_headers()
607            download_response = requests.get(CONTENT_URL, headers=headers)
608            download_response.raise_for_status()
609            content = download_response.content
610            #Process content based on type
611            result = {
612                'content': content,
613                'mime_type': mime_type,
614                'text_content': None,
615                'json_content': None
616            }
617            
618            # Handle text-based files
619            if mime_type.startswith(('text/', 'application/json', 'application/xml' , 'json')):
620                try:
621                    text_content = content.decode('utf-8')
622                    result['text_content'] = text_content
623                    
624                    # Try to parse as JSON
625                    if mime_type == 'application/json':
626                        try:
627                            result['json_content'] = json.loads(text_content)
628                        except json.JSONDecodeError:
629                            pass
630                except UnicodeDecodeError:
631                    logger.error(f"Warning: Could not decode text content for {attachment_id}")
632                    logger.traceback(e)
633            
634            return result
635        except Exception as e:
636            logger.error(f"Error downloading JIRA attachment: {str(e)}")
637            logger.traceback(e)
638            return None

Download a JIRA attachment by its ID.

class XrayGraphQL(JiraHandler):
 641class XrayGraphQL(JiraHandler):
 642    """A comprehensive client for interacting with Xray Cloud's GraphQL API.
 643    This class extends JiraHandler to provide specialized methods for interacting with
 644    Xray Cloud's GraphQL API for test management. It handles authentication, test plans,
 645    test executions, test runs, defects, evidence, and other Xray-related operations 
 646    through GraphQL queries and mutations.
 647    Inherits
 648    --------
 649    JiraHandler
 650        Base class providing JIRA client functionality and issue management
 651    Attributes
 652    ----------
 653    client_id : str
 654        The client ID for Xray authentication
 655    client_secret : str
 656        The client secret for Xray authentication
 657    xray_base_url : str
 658        Base URL for Xray Cloud API (defaults to 'https://us.xray.cloud.getxray.app')
 659    logger : Logger
 660        Logger instance for debugging and error tracking
 661    token : str
 662        Authentication token obtained from Xray
 663    Methods
 664    -------
 665    Authentication & Setup
 666    ---------------------
 667    __init__()
 668        Initialize XrayGraphQL client with authentication and configuration settings.
 669    _get_auth_token() -> Optional[str]
 670        Authenticate with Xray Cloud API and obtain an authentication token.
 671    _make_graphql_request(query: str, variables: Dict) -> Optional[Dict]
 672        Makes a GraphQL request to the Xray API with proper authentication.
 673    _parse_table(table_str: str) -> Dict[str, Union[List[int], List[float]]]
 674        Parse a string representation of a table into a dictionary of numeric values.
 675    Issue ID Management
 676    ------------------
 677    get_issue_id_from_jira_id(issue_key: str, issue_type: str) -> Optional[str]
 678        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
 679    Test Plan Operations
 680    -------------------
 681    get_tests_from_test_plan(test_plan: str) -> Optional[Dict[str, str]]
 682        Retrieves all tests associated with a given test plan.
 683    get_test_plan_data(test_plan: str) -> Optional[Dict[str, Union[List[int], List[float]]]]
 684        Retrieves and parses tabular data from a test plan's description.
 685    Test Set Operations
 686    ------------------
 687    get_tests_from_test_set(test_set: str) -> Optional[Dict[str, str]]
 688        Retrieves all tests associated with a given test set.
 689    filter_test_set_by_test_case(test_key: str) -> Optional[Dict[str, str]]
 690        Retrieves all test sets containing a specific test case.
 691    filter_tags_by_test_case(test_key: str) -> Optional[List[str]]
 692        Extracts and filters tags from test sets associated with a test case.
 693    Test Execution Operations
 694    ------------------------
 695    get_tests_from_test_execution(test_execution: str) -> Optional[Dict[str, str]]
 696        Retrieves all tests associated with a given test execution.
 697    get_test_execution(test_execution: str) -> Optional[Dict]
 698        Retrieve detailed information about a test execution from Xray.
 699    create_test_execution(test_issue_keys: List[str], project_key: Optional[str], 
 700                         summary: Optional[str], description: Optional[str]) -> Optional[Dict]
 701        Creates a new test execution with specified test cases.
 702    create_test_execution_from_test_plan(test_plan: str) -> Optional[Dict[str, Dict[str, str]]]
 703        Creates a test execution from a given test plan with all associated tests.
 704    add_test_execution_to_test_plan(test_plan: str, test_execution: str) -> Optional[Dict]
 705        Add a test execution to an existing test plan in Xray.
 706    Test Run Operations
 707    ------------------
 708    get_test_runstatus(test_case: str, test_execution: str) -> Optional[Tuple[str, str]]
 709        Retrieves the status of a test run for a specific test case.
 710    get_test_run_by_id(test_case_id: str, test_execution_id: str) -> Optional[Tuple[str, str]]
 711        Retrieves the test run ID and status using internal Xray IDs.
 712    update_test_run_status(test_run_id: str, test_run_status: str) -> bool
 713        Updates the status of a specific test run.
 714    update_test_run_comment(test_run_id: str, test_run_comment: str) -> bool
 715        Updates the comment of a specific test run.
 716    get_test_run_comment(test_run_id: str) -> Optional[str]
 717        Retrieve the comment of a specific test run from Xray.
 718    append_test_run_comment(test_run_id: str, test_run_comment: str) -> bool
 719        Append a comment to an existing test run comment.
 720    Evidence & Defect Management
 721    ---------------------------
 722    add_evidence_to_test_run(test_run_id: str, evidence_path: str) -> bool
 723        Add evidence (attachments) to a test run in Xray.
 724    create_defect_from_test_run(test_run_id: str, project_key: str, parent_issue_key: str,
 725                               defect_summary: str, defect_description: str) -> Optional[Dict]
 726        Create a defect from a test run and link it to the test run in Xray.
 727    Examples
 728    --------
 729    >>> client = XrayGraphQL()
 730    >>> test_plan_tests = client.get_tests_from_test_plan("TEST-123")
 731    >>> print(test_plan_tests)
 732    {'TEST-124': '10001', 'TEST-125': '10002'}
 733    >>> test_execution = client.create_test_execution_from_test_plan("TEST-123")
 734    >>> print(test_execution)
 735    {
 736        'TEST-124': {
 737            'test_run_id': '5f7c3',
 738            'test_execution_key': 'TEST-456',
 739            'test_plan_key': 'TEST-123'
 740        }
 741    }
 742    >>> # Update test run status
 743    >>> success = client.update_test_run_status("test_run_id", "PASS")
 744    >>> print(success)
 745    True
 746    >>> # Add evidence to test run
 747    >>> evidence_added = client.add_evidence_to_test_run("test_run_id", "/path/to/screenshot.png")
 748    >>> print(evidence_added)
 749    True
 750    Notes
 751    -----
 752    - Requires valid Xray Cloud credentials (client_id and client_secret)
 753    - Uses GraphQL for all API interactions
 754    - Implements automatic token refresh
 755    - Handles rate limiting and retries
 756    - All methods include comprehensive error handling and logging
 757    - Returns None for failed operations instead of raising exceptions
 758    - Supports various file types for evidence attachments
 759    - Integrates with JIRA for defect creation and issue management
 760    - Provides both synchronous and asynchronous operation patterns
 761    - Includes retry logic for transient failures
 762    """
 763    
 764    def __init__(self):
 765        """Initialize XrayGraphQL client with authentication and configuration settings.    
 766        This constructor sets up the XrayGraphQL client by:
 767        1. Loading environment variables from .env file
 768        2. Reading required environment variables for authentication
 769        3. Configuring the base URL for Xray Cloud
 770        4. Obtaining an authentication token
 771        Required Environment Variables
 772        ----------------------------
 773        XRAY_CLIENT_ID : str
 774            Client ID for Xray authentication
 775        XRAY_CLIENT_SECRET : str
 776            Client secret for Xray authentication
 777        XRAY_BASE_URL : str, optional
 778            Base URL for Xray Cloud API. Defaults to 'https://us.xray.cloud.getxray.app'
 779        Attributes
 780        ----------
 781        client_id : str
 782            Xray client ID from environment
 783        client_secret : str
 784            Xray client secret from environment
 785        xray_base_url : str
 786            Base URL for Xray Cloud API
 787        logger : Logger
 788            Logger instance for debugging and error tracking
 789        token : str
 790            Authentication token obtained from Xray
 791        Raises
 792        ------
 793        Exception
 794            If authentication fails or required environment variables are missing
 795        """
 796        super().__init__()
 797        try:
 798            # Load environment variables from .env file
 799            self.client_id = os.getenv('XRAY_CLIENT_ID')
 800            self.client_secret = os.getenv('XRAY_CLIENT_SECRET')
 801            self.xray_base_url = os.getenv('XRAY_BASE_URL', 'https://us.xray.cloud.getxray.app')
 802            self.logger = logger
 803            # Validate required environment variables
 804            if not self.client_id or self.client_id == '<CLIENT_ID>':
 805                raise ValueError("XRAY_CLIENT_ID environment variable is required")
 806            if not self.client_secret or self.client_secret == '<CLIENT_SECRET>':
 807                raise ValueError("XRAY_CLIENT_SECRET environment variable is required")
 808            # Get authentication token
 809            self.token = self._get_auth_token()
 810            if not self.token:
 811                logger.error("Failed to authenticate with Xray GraphQL")
 812                raise Exception("Failed to initialize XrayGraphQL: No authentication token")
 813        except Exception as e:
 814            logger.error(f"Error initializing XrayGraphQL: {e}")
 815            logger.traceback(e)
 816            raise e
 817    
 818    def _get_auth_token(self) -> Optional[str]:
 819        """Authenticate with Xray Cloud API and obtain an authentication token.
 820        Makes a POST request to the Xray authentication endpoint using the client credentials 
 821        to obtain a JWT token for subsequent API calls.
 822        Returns
 823        -------
 824        Optional[str]
 825            The authentication token if successful, None if authentication fails.
 826            The token is stripped of surrounding quotes before being returned.
 827        Raises
 828        ------
 829        requests.exceptions.RequestException
 830            If the HTTP request fails
 831        requests.exceptions.HTTPError
 832            If the server returns an error status code
 833        Notes
 834        -----
 835        - The token is obtained from the Xray Cloud API endpoint /api/v2/authenticate
 836        - The method uses client credentials stored in self.client_id and self.client_secret
 837        - Failed authentication attempts are logged as errors
 838        - Successful authentication is logged at debug level
 839        """
 840        try:
 841            auth_url = f"{self.xray_base_url}/api/v2/authenticate"
 842            auth_headers = {"Content-Type": "application/json"}
 843            auth_payload = {
 844                "client_id": self.client_id,
 845                "client_secret": self.client_secret
 846            }
 847            # logger.debug("Attempting Xray authentication", "auth_start")
 848            logger.debug("Attempting Xray authentication... auth start")
 849            response = requests.post(auth_url, headers=auth_headers, json=auth_payload)
 850            response.raise_for_status()
 851            token = response.text.strip('"')
 852            # logger.info("Successfully authenticated with Xray", "auth_success")
 853            logger.debug("Successfully authenticated with Xray... auth success again")
 854            return token
 855        except Exception as e:
 856            # logger.error("Xray authentication failed", "auth_failed", error=e)
 857            logger.error("Xray authentication failed. auth failed")
 858            logger.traceback(e)
 859            return None
 860    
 861    def _parse_table(self, table_str: str) -> Dict[str, Union[List[int], List[float]]]:
 862        """Parse a string representation of a table into a dictionary of numeric values.
 863        Parameters
 864        ----------
 865        table_str : str
 866            A string containing the table data in markdown-like format.
 867            Example format::
 868                header1 || header2 || header3
 869                |row1   |value1    |[1, 2, 3]|
 870                |row2   |value2    |42       |
 871        Returns
 872        -------
 873        Dict[str, Union[List[int], List[float]]]
 874            A dictionary where:
 875            * Keys are strings derived from the first column (lowercase, underscores)
 876            * Values are either:
 877                * Lists of integers/floats (for array-like values in brackets)
 878                * Lists of individual numbers (for single numeric values)
 879            For non-array columns, duplicate values are removed and sorted.
 880            Returns None if parsing fails.
 881        Examples
 882        --------
 883        >>> table_str = '''header1 || header2
 884        ...                |temp   |[1, 2, 3]|
 885        ...                |value  |42       |'''
 886        >>> result = client._parse_table(table_str)
 887        >>> print(result)
 888        {
 889            'temp': [1, 2, 3],
 890            'value': [42]
 891        }
 892        """
 893        try:
 894            # Split the table into lines
 895            lines = table_str.strip().split('\n')
 896            # Process each data row
 897            result = {}
 898            for line in lines[1:]:
 899                if not line.startswith('|'):
 900                    continue
 901                # Split the row into columns
 902                columns = [col.strip() for col in line.split('|')[1:-1]]
 903                if not columns:
 904                    continue
 905                key = columns[0].replace(' ', '_').lower()
 906                values = []
 907                # Process each value column
 908                for col in columns[1:]:
 909                    # Handle list values
 910                    if col.startswith('[') and col.endswith(']'):
 911                        try:
 912                            # Clean and parse the list
 913                            list_str = col[1:-1].replace(',', ' ')
 914                            list_items = [item.strip() for item in list_str.split() if item.strip()]
 915                            num_list = [float(item) if '.' in item else int(item) for item in list_items]
 916                            values.append(num_list)
 917                        except (ValueError, SyntaxError):
 918                            pass
 919                    elif col.strip():  # Handle simple numeric values
 920                        try:
 921                            num = float(col) if '.' in col else int(col)
 922                            values.append(num)
 923                        except ValueError:
 924                            pass
 925                # Store in result
 926                if key:
 927                    if key in result:
 928                        result[key].extend(values)
 929                    else:
 930                        result[key] = values
 931            # For temperature (simple values), remove duplicates and sort
 932            for key in result:
 933                if all(not isinstance(v, list) for v in result[key]):
 934                    result[key] = sorted(list(set(result[key])))
 935            return result
 936        except Exception as e:
 937            logger.error(f"Error parsing table: {e}")
 938            logger.traceback(e)
 939            return None
 940    
 941    def _make_graphql_request(self, query: str, variables: Dict) -> Optional[Dict]:
 942        """
 943        Makes a GraphQL request to the Xray API with the provided query and variables.
 944        This internal method handles the execution of GraphQL queries against the Xray API,
 945        including proper authentication and error handling.
 946        Args:
 947            query (str): The GraphQL query or mutation to execute
 948            variables (Dict): Variables to be used in the GraphQL query/mutation
 949        Returns:
 950            Optional[Dict]: The 'data' field from the GraphQL response if successful,
 951                           None if the request fails or contains GraphQL errors
 952        Raises:
 953            No exceptions are raised - all errors are caught, logged, and return None
 954        Example:
 955            query = '''
 956                query GetTestPlan($id: String!) {
 957                    getTestPlan(issueId: $id) {
 958                        issueId
 959                    }
 960                }
 961            '''
 962            variables = {"id": "12345"}
 963            result = self._make_graphql_request(query, variables)
 964        Note:
 965            - Automatically includes authentication token in request headers
 966            - Logs errors if the request fails or if GraphQL errors are present
 967            - Returns None instead of raising exceptions to allow for graceful error handling
 968            - Only returns the 'data' portion of the GraphQL response
 969        """
 970        try:
 971            graphql_url = f"{self.xray_base_url}/api/v2/graphql"
 972            headers = {
 973                "Authorization": f"Bearer {self.token}",
 974                "Content-Type": "application/json"
 975            }
 976            payload = {"query": query, "variables": variables}
 977            # logger.debug(f'Making GraphQL request "query": {query}, "variables": {variables} ')
 978            response = requests.post(graphql_url, headers=headers, json=payload)
 979            jprint(response.json())
 980
 981            response.raise_for_status()
 982            try:
 983                data = response.json()
 984                jprint(data)
 985            except:
 986                data = response.text
 987                logger.debug(f"Response text: {data}")
 988            if 'errors' in data:
 989                logger.error(f'GraphQL request failed: {data["errors"]}')
 990                return None
 991            return data['data']
 992        except Exception as e:
 993            logger.error(f"GraphQL request failed due to {e} ")
 994            logger.traceback(e)
 995            return None
 996    
 997    def get_issue_id_from_jira_id(self, issue_key: str, issue_type: str) -> Optional[str]:
 998        """
 999        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
1000        This method queries the Xray GraphQL API to find the internal issue ID corresponding
1001        to a JIRA issue key. It supports different types of Xray artifacts including test plans,
1002        test executions, test sets, and tests.
1003        Args:
1004            issue_key (str): The JIRA issue key (e.g., "PROJECT-123")
1005            issue_type (str): The type of Xray artifact. Supported values are:
1006                - "plan" or contains "plan": For Test Plans
1007                - "exec" or contains "exec": For Test Executions
1008                - "set" or contains "set": For Test Sets
1009                - "test" or contains "test": For Tests
1010                If not provided, defaults to "plan"
1011        Returns:
1012            Optional[str]: The internal Xray issue ID if found, None if:
1013                - The issue key doesn't exist
1014                - The GraphQL request fails
1015                - Any other error occurs during processing
1016        Examples:
1017            >>> client.get_issue_id_from_jira_id("TEST-123", "plan")
1018            '10000'
1019            >>> client.get_issue_id_from_jira_id("TEST-456", "test")
1020            '10001'
1021            >>> client.get_issue_id_from_jira_id("INVALID-789", "plan")
1022            None
1023        Note:
1024            The method performs a case-insensitive comparison when matching issue keys.
1025            The project key is extracted from the issue_key (text before the hyphen)
1026            to filter results by project.
1027        """
1028        try:
1029            parse_project = issue_key.split("-")[0]
1030            function_name = "getTestPlans"
1031            if not issue_type:
1032                issue_type = "plan"
1033            if "plan" in issue_type.lower():
1034                function_name = "getTestPlans"
1035                query = """
1036                    query GetIds($limit: Int!, $jql: String!) {    
1037                        getTestPlans(limit: $limit, jql:$jql) {
1038                            results {
1039                                issueId
1040                                jira(fields: ["key"])
1041                            }
1042                        }
1043                    }
1044                    """
1045            if "exec" in issue_type.lower():
1046                function_name = "getTestExecutions"
1047                query = """
1048                    query GetIds($limit: Int!, $jql: String!) {    
1049                        getTestExecutions(limit: $limit, jql:$jql) {
1050                            results {
1051                                issueId
1052                                jira(fields: ["key"])
1053                            }
1054                        }
1055                    }
1056                    """
1057            if "set" in issue_type.lower():
1058                function_name = "getTestSets"
1059                query = """
1060                    query GetIds($limit: Int!, $jql: String!) {    
1061                        getTestSets(limit: $limit, jql:$jql) {
1062                            results {
1063                                issueId
1064                                jira(fields: ["key"])
1065                            }
1066                        }
1067                    }
1068                    """
1069            if "test" in issue_type.lower():
1070                function_name = "getTests"
1071                query = """
1072                    query GetIds($limit: Int!, $jql: String!) {    
1073                        getTests(limit: $limit, jql:$jql) {
1074                            results {
1075                                issueId
1076                                jira(fields: ["key"])
1077                            }
1078                        }
1079                    }
1080                    """
1081            variables = {
1082                "limit": 10,
1083                "jql":  f"project = '{parse_project}' AND key = '{issue_key}'"
1084            }
1085            data = self._make_graphql_request(query, variables)
1086            if not data:
1087                logger.error(f"Failed to get issue ID for {issue_key}")
1088                return None
1089            for issue in data[function_name]['results']:
1090                if str(issue['jira']['key']).lower() == issue_key.lower():
1091                    return issue['issueId']
1092            return None
1093        except Exception as e:
1094            logger.error(f"Failed to get issue ID for {issue_key}")
1095            logger.traceback(e)
1096            return None
1097    
1098    def get_test_details(self, issue_key: str, issue_type: str) -> Optional[str]:
1099        """
1100        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
1101        This method queries the Xray GraphQL API to find the internal issue ID corresponding
1102        to a JIRA issue key. It supports different types of Xray artifacts including test plans,
1103        test executions, test sets, and tests.
1104        Args:
1105            issue_key (str): The JIRA issue key (e.g., "PROJECT-123")
1106            issue_type (str): The type of Xray artifact. Supported values are:
1107                - "plan" or contains "plan": For Test Plans
1108                - "exec" or contains "exec": For Test Executions
1109                - "set" or contains "set": For Test Sets
1110                - "test" or contains "test": For Tests
1111                If not provided, defaults to "plan"
1112        Returns:
1113            Optional[str]: The internal Xray issue ID if found, None if:
1114                - The issue key doesn't exist
1115                - The GraphQL request fails
1116                - Any other error occurs during processing
1117        Examples:
1118            >>> client.get_issue_id_from_jira_id("TEST-123", "plan")
1119            '10000'
1120            >>> client.get_issue_id_from_jira_id("TEST-456", "test")
1121            '10001'
1122            >>> client.get_issue_id_from_jira_id("INVALID-789", "plan")
1123            None
1124        Note:
1125            The method performs a case-insensitive comparison when matching issue keys.
1126            The project key is extracted from the issue_key (text before the hyphen)
1127            to filter results by project.
1128        """
1129        try:
1130            parse_project = issue_key.split("-")[0]
1131            function_name = "getTestPlans"
1132            if not issue_type:
1133                issue_type = "plan"
1134            if "plan" in issue_type.lower():
1135                function_name = "getTestPlans"
1136                jira_fields = [
1137                    "key", "summary", "description", "assignee", 
1138                    "status", "priority", "labels", "created", 
1139                    "updated", "dueDate", "components", "versions", 
1140                    "attachments", "comments"
1141                ]
1142                query = """
1143                    query GetDetails($limit: Int!, $jql: String!) {    
1144                        getTestPlans(limit: $limit, jql:$jql) {
1145                            results {
1146                                issueId
1147                                jira(fields: ["key"])
1148                            }
1149                        }
1150                    }
1151                    """
1152            if "exec" in issue_type.lower():
1153                function_name = "getTestExecutions"
1154                jira_fields = [
1155                    "key", "summary", "description", "assignee", 
1156                    "status", "priority", "labels", "created", 
1157                    "updated", "dueDate", "components", "versions", 
1158                    "attachments", "comments"
1159                ]
1160                query = """
1161                    query GetDetails($limit: Int!, $jql: String!) {    
1162                        getTestExecutions(limit: $limit, jql:$jql) {
1163                            results {
1164                                issueId
1165                                jira(fields: ["key"])
1166                            }
1167                        }
1168                    }
1169                    """
1170            if "set" in issue_type.lower():
1171                function_name = "getTestSets"
1172                jira_fields = [
1173                    "key", "summary", "description", "assignee", 
1174                    "status", "priority", "labels", "created", 
1175                    "updated", "dueDate", "components", "versions", 
1176                    "attachments", "comments"
1177                ]
1178                query = """
1179                    query GetDetails($limit: Int!, $jql: String!) {    
1180                        getTestSets(limit: $limit, jql:$jql) {
1181                            results {
1182                                issueId
1183                                jira(fields: ["key"])
1184                            }
1185                        }
1186                    }
1187                    """
1188            if "test" in issue_type.lower():
1189                function_name = "getTests"
1190                jira_fields = [
1191                    "key", "summary", "description", "assignee", 
1192                    "status", "priority", "labels", "created", 
1193                    "updated", "dueDate", "components", "versions", 
1194                    "attachments", "comments"
1195                ]
1196                query = """
1197                    query GetDetails($limit: Int!, $jql: String!, $jiraFields: [String!]!) {    
1198                        getTests(limit: $limit, jql:$jql) {
1199                            results {
1200                                issueId
1201                                jira(fields: $jiraFields)
1202                                steps {
1203                                    id
1204                                    action
1205                                    result
1206                                    attachments {
1207                                    id
1208                                    filename
1209                                    storedInJira
1210                                    downloadLink
1211                                    }
1212                                }
1213                                
1214                            }
1215                        }
1216                    }
1217                    """
1218            variables = {
1219                "limit": 10,
1220                "jql": f"project = '{parse_project}' AND key = '{issue_key}'",
1221                "jiraFields": jira_fields
1222            }
1223            data = self._make_graphql_request(query, variables)
1224            if not data:
1225                logger.error(f"Failed to get issue ID for {issue_key}")
1226                return None
1227            for issue in data[function_name]['results']:
1228                if str(issue['jira']['key']).lower() == issue_key.lower():
1229                    return issue  # This now includes all metadata
1230            return None
1231        except Exception as e:
1232            logger.error(f"Failed to get issue ID for {issue_key}")
1233            logger.traceback(e)
1234            return None
1235    
1236    def get_tests_from_test_plan(self, test_plan: str) -> Optional[Dict[str, str]]:
1237        """
1238        Retrieves all tests associated with a given test plan from Xray.
1239        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1240        test plan. It first converts the JIRA test plan key to an internal Xray ID, then uses that
1241        ID to fetch the associated tests.
1242        Args:
1243            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1244        Returns:
1245            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1246                or None if the operation fails. For example:
1247                {
1248                    "PROJECT-124": "10001",
1249                    "PROJECT-125": "10002"
1250                }
1251                Returns None in the following cases:
1252                - Test plan ID cannot be found
1253                - GraphQL request fails
1254                - Any other error occurs during processing
1255        Example:
1256            >>> client = XrayGraphQL()
1257            >>> tests = client.get_tests_from_test_plan("PROJECT-123")
1258            >>> print(tests)
1259            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1260        Note:
1261            - The method is limited to retrieving 99999 tests per test plan
1262            - Test plan must exist in Xray and be accessible with current authentication
1263        """
1264        try:
1265            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1266            if not test_plan_id:
1267                logger.error(f"Failed to get test plan ID for {test_plan}")
1268                return None
1269            query = """
1270            query GetTestPlanTests($testPlanId: String!) {
1271                getTestPlan(issueId: $testPlanId) {
1272                    tests(limit: 99999) {
1273                        results {   
1274                            issueId
1275                            jira(fields: ["key"])
1276                        }
1277                    }
1278                }
1279            }
1280            """
1281            variables = {"testPlanId": test_plan_id}
1282            data = self._make_graphql_request(query, variables)
1283            if not data:
1284                logger.error(f"Failed to get tests for plan {test_plan_id}")
1285                return None
1286            tests = {}
1287            for test in data['getTestPlan']['tests']['results']:
1288                tests[test['jira']['key']] = test['issueId']
1289            return tests
1290        except Exception as e:
1291            logger.error(f"Failed to get tests for plan {test_plan_id}")
1292            logger.traceback(e)
1293            return None
1294    
1295    def get_tests_from_test_set(self, test_set: str) -> Optional[Dict[str, str]]:
1296        """
1297        Retrieves all tests associated with a given test set from Xray.
1298        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1299        test set. It first converts the JIRA test set key to an internal Xray ID, then uses that
1300        ID to fetch the associated tests.
1301        Args:
1302            test_set (str): The JIRA issue key of the test set (e.g., "PROJECT-123")
1303        Returns:
1304            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1305                or None if the operation fails. For example:
1306                {
1307                    "PROJECT-124": "10001",
1308                    "PROJECT-125": "10002"
1309                }
1310                Returns None in the following cases:
1311                - Test set ID cannot be found
1312                - GraphQL request fails
1313                - Any other error occurs during processing
1314        Example:
1315            >>> client = XrayGraphQL()
1316            >>> tests = client.get_tests_from_test_set("PROJECT-123")
1317            >>> print(tests)
1318            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1319        Note:
1320            - The method is limited to retrieving 99999 tests per test set
1321            - Test set must exist in Xray and be accessible with current authentication
1322        """
1323        try:
1324            test_set_id = self.get_issue_id_from_jira_id(test_set, "set")
1325            if not test_set_id:
1326                logger.error(f"Failed to get test set ID for {test_set}")
1327                return None
1328            query = """
1329            query GetTestSetTests($testSetId: String!) {
1330                getTestSet(issueId: $testSetId) {
1331                    tests(limit: 99999) {
1332                        results {   
1333                            issueId
1334                            jira(fields: ["key"])
1335                        }
1336                    }
1337                }
1338            }
1339            """
1340            variables = {"testSetId": test_set_id}
1341            data = self._make_graphql_request(query, variables)
1342            if not data:
1343                logger.error(f"Failed to get tests for set {test_set_id}")
1344                return None
1345            tests = {}
1346            for test in data['getTestSet']['tests']['results']:
1347                tests[test['jira']['key']] = test['issueId']
1348            return tests
1349        except Exception as e:
1350            logger.error(f"Failed to get tests for set {test_set_id}")
1351            logger.traceback(e)
1352            return None
1353    
1354    def get_tests_from_test_execution(self, test_execution: str) -> Optional[Dict[str, str]]:
1355        """
1356        Retrieves all tests associated with a given test execution from Xray.
1357        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1358        test execution. It first converts the JIRA test execution key to an internal Xray ID, then uses that
1359        ID to fetch the associated tests.
1360        Args:
1361            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123")
1362        Returns:
1363            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1364                or None if the operation fails. For example:
1365                {
1366                    "PROJECT-124": "10001",
1367                    "PROJECT-125": "10002"
1368                }
1369                Returns None in the following cases:
1370                - Test execution ID cannot be found
1371                - GraphQL request fails
1372                - Any other error occurs during processing
1373        Example:
1374            >>> client = XrayGraphQL()
1375            >>> tests = client.get_tests_from_test_execution("PROJECT-123")
1376            >>> print(tests)
1377            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1378        Note:
1379            - The method is limited to retrieving 99999 tests per test execution
1380            - Test execution must exist in Xray and be accessible with current authentication
1381        """
1382        try:
1383            test_execution_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1384            if not test_execution_id:
1385                logger.error(f"Failed to get test execution ID for {test_execution}")
1386                return None
1387            query = """
1388            query GetTestExecutionTests($testExecutionId: String!) {
1389                getTestExecution(issueId: $testExecutionId) {
1390                    tests(limit: 100) {
1391                        results {   
1392                            issueId
1393                            jira(fields: ["key"])
1394                        }
1395                    }
1396                }
1397            }
1398            """
1399            variables = {"testExecutionId": test_execution_id}
1400            data = self._make_graphql_request(query, variables)
1401            if not data:
1402                logger.error(f"Failed to get tests for execution {test_execution_id}")
1403                return None
1404            tests = {}
1405            for test in data['getTestExecution']['tests']['results']:
1406                tests[test['jira']['key']] = test['issueId']
1407            return tests
1408        except Exception as e:
1409            logger.error(f"Failed to get tests for execution {test_execution_id}")
1410            logger.traceback(e)
1411            return None
1412    
1413    def get_test_plan_data(self, test_plan: str) -> Optional[Dict[str, Union[List[int], List[float]]]]:
1414        """
1415        Retrieve and parse tabular data from a test plan's description field in Xray.
1416        This method fetches a test plan's description from Xray and parses any tables found within it.
1417        The tables in the description are expected to be in a specific format that can be parsed by
1418        the _parse_table method. The parsed data is returned as a dictionary containing numeric values
1419        and lists extracted from the table.
1420        Args:
1421            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1422        Returns:
1423            Optional[Dict[str, Union[List[int], List[float]]]]: A dictionary containing the parsed table data
1424                where keys are derived from the first column of the table and values are lists of numeric
1425                values. Returns None if:
1426                - The test plan ID cannot be found
1427                - The GraphQL request fails
1428                - The description cannot be parsed
1429                - Any other error occurs during processing
1430        Example:
1431            >>> client = XrayGraphQL()
1432            >>> data = client.get_test_plan_data("TEST-123")
1433            >>> print(data)
1434            {
1435                'temperature': [20, 25, 30],
1436                'pressure': [1.0, 1.5, 2.0],
1437                'measurements': [[1, 2, 3], [4, 5, 6]]
1438            }
1439        Note:
1440            - The test plan must exist in Xray and be accessible with current authentication
1441            - The description must contain properly formatted tables for parsing
1442            - Table values are converted to numeric types (int or float) where possible
1443            - Lists in table cells should be formatted as [value1, value2, ...]
1444            - Failed operations are logged as errors with relevant details
1445        """
1446        try:
1447            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1448            if not test_plan_id:
1449                logger.error(f"Failed to get test plan ID for {test_plan}")
1450                return None
1451            query = """
1452            query GetTestPlanTests($testPlanId: String!) {
1453                getTestPlan(issueId: $testPlanId) {
1454                    issueId
1455                    jira(fields: ["key","description"])
1456                }
1457            }
1458            """
1459            variables = {"testPlanId": test_plan_id}
1460            data = self._make_graphql_request(query, variables)
1461            if not data:
1462                logger.error(f"Failed to get tests for plan {test_plan_id}")
1463                return None
1464            description = data['getTestPlan']['jira']['description']
1465            test_plan_data = self._parse_table(description)
1466            return test_plan_data            
1467        except Exception as e:
1468            logger.error(f"Failed to get tests for plan {test_plan_id}")
1469            logger.traceback(e)
1470            return None
1471    
1472    def filter_test_set_by_test_case(self, test_key: str) -> Optional[Dict[str, str]]:
1473        """
1474        Retrieves all test sets that contain a specific test case from Xray.
1475        This method queries the Xray GraphQL API to find all test sets that include the specified
1476        test case. It first converts the JIRA test case key to an internal Xray ID, then uses that
1477        ID to fetch all associated test sets.
1478        Args:
1479            test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1480        Returns:
1481            Optional[Dict[str, str]]: A dictionary mapping test set JIRA keys to their summaries,
1482                or None if the operation fails. For example:
1483                {
1484                    "PROJECT-124": "Test Set for Feature A",
1485                    "PROJECT-125": "Regression Test Set"
1486                }
1487                Returns None in the following cases:
1488                - Test case ID cannot be found
1489                - GraphQL request fails
1490                - Any other error occurs during processing
1491        Example:
1492            >>> client = XrayGraphQL()
1493            >>> test_sets = client.filter_test_set_by_test_case("PROJECT-123")
1494            >>> print(test_sets)
1495            {"PROJECT-124": "Test Set for Feature A", "PROJECT-125": "Regression Test Set"}
1496        Note:
1497            - The method is limited to retrieving 99999 test sets per test case
1498            - Test case must exist in Xray and be accessible with current authentication
1499        """
1500        try:
1501            test_id = self.get_issue_id_from_jira_id(test_key, "test")
1502            if not test_id:
1503                logger.error(f"Failed to get test ID for  Test Case ({test_key})")
1504                return None
1505            query = """
1506            query GetTestDetails($testId: String!) {
1507                getTest(issueId: $testId) {
1508                    testSets(limit: 100) {
1509                        results {   
1510                            issueId
1511                            jira(fields: ["key","summary"])
1512                        }
1513                    }
1514                }
1515            }   
1516            """
1517            variables = {
1518                "testId": test_id
1519            }
1520            data = self._make_graphql_request(query, variables)
1521            if not data:
1522                logger.error(f"Failed to get tests for plan {test_id}")
1523                return None
1524            retDict = {}
1525            for test in data['getTest']['testSets']['results']:
1526                retDict[test['jira']['key']] = test['jira']['summary']
1527            return retDict
1528        except Exception as e:
1529            logger.error(f"Error in getting test set by test id: {e}")
1530            logger.traceback(e)
1531            return None 
1532    
1533    def filter_tags_by_test_case(self, test_key: str) -> Optional[List[str]]:
1534        """
1535        Extract and filter tags from test sets associated with a specific test case in Xray.
1536        This method queries the Xray GraphQL API to find all test sets associated with the given
1537        test case and extracts tags from their summaries. Tags are identified from test set summaries
1538        that start with either 'tag' or 'benchtype' prefixes.
1539        Args:
1540            test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1541        Returns:
1542            Optional[List[str]]: A list of unique tags extracted from test set summaries,
1543                or None if no tags are found or an error occurs. Tags are:
1544                - Extracted from summaries starting with 'tag:' or 'benchtype:'
1545                - Split on commas, semicolons, double pipes, or whitespace
1546                - Converted to lowercase and stripped of whitespace
1547        Example:
1548            >>> client = XrayGraphQL()
1549            >>> tags = client.filter_tags_by_test_case("TEST-123")
1550            >>> print(tags)
1551            ['regression', 'smoke', 'performance']
1552        Note:
1553            - Test sets must have summaries in the format "tag: tag1, tag2" or "benchtype: type1, type2"
1554            - Tags are extracted only from summaries with the correct prefix
1555            - All tags are converted to lowercase for consistency
1556            - Duplicate tags are automatically removed via set conversion
1557            - Returns None if no valid tags are found or if an error occurs
1558        """
1559        try:
1560            test_id = self.get_issue_id_from_jira_id(test_key, "test")
1561            if not test_id:
1562                logger.error(f"Failed to get test ID for  Test Case ({test_key})")
1563                return None
1564            query = """
1565            query GetTestDetails($testId: String!) {
1566                getTest(issueId: $testId) {
1567                    testSets(limit: 100) {
1568                        results {   
1569                            issueId
1570                            jira(fields: ["key","summary"])
1571                        }
1572                    }
1573                }
1574            }   
1575            """
1576            variables = {
1577                "testId": test_id
1578            }
1579            data = self._make_graphql_request(query, variables)
1580            if not data:
1581                logger.error(f"Failed to get tests for plan {test_id}")
1582                return None
1583            tags = set()
1584            for test in data['getTest']['testSets']['results']:
1585                summary = str(test['jira']['summary']).strip().lower()
1586                if summary.startswith(('tag', 'benchtype')):
1587                    summary = summary.split(':', 1)[-1].strip()  # Split only once and take last part
1588                    tags.update(tag for tag in re.split(r'[,|;|\|\|]\s*|\s+', summary) if tag)
1589            if tags:
1590                return list(tags)
1591            else:
1592                return None
1593        except Exception as e:
1594            logger.error(f"Error in getting test set by test id: {e}")
1595            logger.traceback(e)
1596            return None 
1597    
1598    def get_test_runstatus(self, test_case: str, test_execution: str) -> Optional[Tuple[str, str]]:
1599        """
1600        Retrieve the status of a test run for a specific test case within a test execution.
1601        This method queries the Xray GraphQL API to get the current status of a test run,
1602        which represents the execution status of a specific test case within a test execution.
1603        It first converts both the test case and test execution JIRA keys to their internal
1604        Xray IDs, then uses these to fetch the test run status.
1605        Args:
1606            test_case (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1607            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-456")
1608        Returns:
1609            Tuple[Optional[str], Optional[str]]: A tuple containing:
1610                - test_run_id: The unique identifier of the test run (or None if not found)
1611                - test_run_status: The current status of the test run (or None if not found)
1612                Returns (None, None) if any error occurs during the process.
1613        Example:
1614            >>> client = XrayGraphQL()
1615            >>> run_id, status = client.get_test_runstatus("TEST-123", "TEST-456")
1616            >>> print(f"Test Run ID: {run_id}, Status: {status}")
1617            Test Run ID: 10001, Status: PASS
1618        Note:
1619            - Both the test case and test execution must exist in Xray and be accessible
1620            - The test case must be associated with the test execution
1621            - The method performs two ID lookups before querying the test run status
1622            - Failed operations are logged as errors with relevant details
1623        """
1624        try:
1625            test_case_id = self.get_issue_id_from_jira_id(test_case, "test")
1626            if not test_case_id:
1627                logger.error(f"Failed to get test ID for Test Case ({test_case})")
1628                return None
1629            test_exec_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1630            if not test_exec_id:
1631                logger.error(f"Failed to get test execution ID for Test Execution ({test_execution})")
1632                return None
1633            query = """
1634            query GetTestRunStatus($testId: String!, $testExecutionId: String!) {
1635                getTestRun( testIssueId: $testId, testExecIssueId: $testExecutionId) {
1636                    id
1637                    status {
1638                        name
1639                    }
1640                }
1641            }
1642            """
1643            variables = {
1644                "testId": test_case_id,
1645                "testExecutionId": test_exec_id,
1646            }
1647            # Add debug loggerging
1648            logger.debug(f"Getting test run status for test {test_case_id} in execution {test_exec_id}")
1649            data = self._make_graphql_request(query, variables)
1650            if not data:
1651                logger.error(f"Failed to get test run status for test {test_case_id}")
1652                return None
1653            # jprint(data)
1654            test_run_id = data['getTestRun']['id']
1655            test_run_status = data['getTestRun']['status']['name']
1656            return (test_run_id, test_run_status)
1657        except Exception as e:
1658            logger.error(f"Error getting test run status: {str(e)}")
1659            logger.traceback(e)
1660            return (None, None)
1661    
1662    def get_test_run_by_id(self, test_case_id: str, test_execution_id: str) -> Optional[Tuple[str, str]]:
1663        """
1664        Retrieves the test run ID and status for a specific test case within a test execution using GraphQL.
1665        Args:
1666            test_case_id (str): The ID of the test case to query
1667            test_execution_id (str): The ID of the test execution containing the test run
1668        Returns:
1669            tuple[Optional[str], Optional[str]]: A tuple containing:
1670                - test_run_id: The ID of the test run if found, None if not found or on error
1671                - test_run_status: The status name of the test run if found, None if not found or on error
1672        Note:
1673            The function makes a GraphQL request to fetch the test run information. If the request fails
1674            or encounters any errors, it will log the error and return (None, None).
1675        """
1676        try:
1677            query = """
1678            query GetTestRunStatus($testId: String!, $testExecutionId: String!) {
1679                getTestRun( testIssueId: $testId, testExecIssueId: $testExecutionId) {
1680                    id
1681                    status {
1682                        name
1683                    }
1684                }
1685            }
1686            """
1687            variables = {
1688                "testId": test_case_id,
1689                "testExecutionId": test_execution_id,
1690            }
1691            # Add debug loggerging
1692            logger.debug(f"Getting test run status for test {test_case_id} in execution {test_execution_id}")
1693            data = self._make_graphql_request(query, variables)
1694            if not data:
1695                logger.error(f"Failed to get test run status for test {test_case_id}")
1696                return None
1697            test_run_id = data['getTestRun']['id']
1698            test_run_status = data['getTestRun']['status']['name']
1699            return (test_run_id, test_run_status)
1700        except Exception as e:
1701            logger.error(f"Error getting test run status: {str(e)}")
1702            logger.traceback(e)
1703            return (None, None)
1704    
1705    def get_test_execution(self, test_execution: str) -> Optional[Dict]:
1706        """
1707        Retrieve detailed information about a test execution from Xray.
1708        This method queries the Xray GraphQL API to fetch information about a specific test execution,
1709        including its ID and associated tests. It first converts the JIRA test execution key to an
1710        internal Xray ID, then uses that ID to fetch the execution details.
1711        Args:
1712            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123")
1713        Returns:
1714            Optional[Dict]: A dictionary containing test execution details if successful, None if failed.
1715                The dictionary has the following structure:
1716                {
1717                    'id': str,          # The internal Xray ID of the test execution
1718                    'tests': {          # Dictionary mapping test keys to their IDs
1719                        'TEST-124': '10001',
1720                        'TEST-125': '10002',
1721                        ...
1722                    }
1723                }
1724                Returns None in the following cases:
1725                - Test execution ID cannot be found
1726                - GraphQL request fails
1727                - No test execution found with the given ID
1728                - No tests found in the test execution
1729                - Any other error occurs during processing
1730        Example:
1731            >>> client = XrayGraphQL()
1732            >>> execution = client.get_test_execution("TEST-123")
1733            >>> print(execution)
1734            {
1735                'id': '10000',
1736                'tests': {
1737                    'TEST-124': '10001',
1738                    'TEST-125': '10002'
1739                }
1740            }
1741        Note:
1742            - The method is limited to retrieving 99999 tests per test execution
1743            - Test execution must exist in Xray and be accessible with current authentication
1744            - Failed operations are logged with appropriate error or warning messages
1745        """
1746        try:
1747            test_execution_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1748            if not test_execution_id:
1749                logger.error(f"Failed to get test execution ID for {test_execution}")
1750                return None
1751            query = """
1752            query GetTestExecution($testExecutionId: String!) {
1753                getTestExecution(issueId: $testExecutionId) {
1754                    issueId
1755                    projectId
1756                    jira(fields: ["key", "summary", "description", "status"])
1757                    tests(limit: 100) {
1758                        total
1759                        start
1760                        limit
1761                        results {
1762                            issueId
1763                            jira(fields: ["key"])
1764                        }
1765                    }
1766                }
1767            }
1768            """
1769            variables = {
1770                "testExecutionId": test_execution_id
1771            }
1772            # Add debug loggerging
1773            logger.debug(f"Getting test execution details for {test_execution_id}")
1774            data = self._make_graphql_request(query, variables)
1775            # jprint(data)
1776            if not data:
1777                logger.error(f"Failed to get test execution details for {test_execution_id}")
1778                return None
1779            test_execution = data.get('getTestExecution',{})
1780            if not test_execution:
1781                logger.warning(f"No test execution found with ID {test_execution_id}")
1782                return None
1783            tests = test_execution.get('tests',{})
1784            if not tests:
1785                logger.warning(f"No tests found for test execution {test_execution_id}")
1786                return None
1787            tests_details = dict()
1788            for test in tests['results']:
1789                tests_details[test['jira']['key']] = test['issueId']
1790            formatted_response = {
1791                'id': test_execution['issueId'],
1792                'tests': tests_details
1793            }
1794            return formatted_response
1795        except Exception as e:
1796            logger.error(f"Error getting test execution details: {str(e)}")
1797            logger.traceback(e)
1798            return None
1799    
1800    def add_test_execution_to_test_plan(self, test_plan: str, test_execution: str) -> Optional[Dict]:
1801        """
1802        Add a test execution to an existing test plan in Xray.
1803        This method associates a test execution with a test plan using the Xray GraphQL API.
1804        It first converts both the test plan and test execution JIRA keys to their internal
1805        Xray IDs, then creates the association between them.
1806        Args:
1807            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1808            test_execution (str): The JIRA issue key of the test execution to add (e.g., "PROJECT-456")
1809        Returns:
1810            Optional[Dict]: A dictionary containing the response data if successful, None if failed.
1811                The dictionary has the following structure:
1812                {
1813                    'addTestExecutionsToTestPlan': {
1814                        'addedTestExecutions': [str],  # List of added test execution IDs
1815                        'warning': str                 # Any warnings from the operation
1816                    }
1817                }
1818                Returns None in the following cases:
1819                - Test plan ID cannot be found
1820                - Test execution ID cannot be found
1821                - GraphQL request fails
1822                - Any other error occurs during processing
1823        Example:
1824            >>> client = XrayGraphQL()
1825            >>> result = client.add_test_execution_to_test_plan("TEST-123", "TEST-456")
1826            >>> print(result)
1827            {
1828                'addTestExecutionsToTestPlan': {
1829                    'addedTestExecutions': ['10001'],
1830                    'warning': None
1831                }
1832            }
1833        Note:
1834            - Both the test plan and test execution must exist in Xray and be accessible
1835            - The method performs two ID lookups before creating the association
1836            - Failed operations are logged as errors with relevant details
1837        """
1838        try:
1839            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1840            if not test_plan_id:
1841                logger.error(f"Test plan ID is required")
1842                return None
1843            test_exec_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1844            if not test_exec_id:
1845                logger.error(f"Test execution ID is required")
1846                return None
1847            query = """
1848            mutation AddTestExecutionToTestPlan($testPlanId: String!, $testExecutionIds: [String!]!) {
1849                addTestExecutionsToTestPlan(issueId: $testPlanId, testExecIssueIds: $testExecutionIds) {
1850                    addedTestExecutions 
1851                    warning
1852                }
1853            }
1854            """
1855            variables = {
1856                "testPlanId": test_plan_id,
1857                "testExecutionIds": [test_exec_id]
1858            }
1859            data = self._make_graphql_request(query, variables)
1860            return data
1861        except Exception as e:
1862            logger.error(f"Error adding test execution to test plan: {str(e)}")
1863            logger.traceback(e)
1864            return None
1865    
1866    def create_test_execution(self, 
1867                            test_issue_keys: List[str], 
1868                            project_key: Optional[str] = None, 
1869                            summary: Optional[str] = None, 
1870                            description: Optional[str] = None) -> Optional[Dict]:
1871        """
1872        Create a new test execution in Xray with specified test cases.
1873        This method creates a test execution ticket in JIRA/Xray that includes the specified test cases.
1874        It handles validation of test issue keys, automatically derives project information if not provided,
1875        and creates appropriate default values for summary and description if not specified.
1876        Args:
1877            test_issue_keys (List[str]): List of JIRA issue keys for test cases to include in the execution
1878                (e.g., ["TEST-123", "TEST-124"])
1879            project_key (Optional[str]): The JIRA project key where the test execution should be created.
1880                If not provided, it will be derived from the first test issue key.
1881            summary (Optional[str]): The summary/title for the test execution ticket.
1882                If not provided, a default summary will be generated using the test issue keys.
1883            description (Optional[str]): The description for the test execution ticket.
1884                If not provided, a default description will be generated using the test issue keys.
1885        Returns:
1886            Optional[Dict]: A dictionary containing the created test execution details if successful,
1887                None if the creation fails. The dictionary has the following structure:
1888                {
1889                    'issueId': str,      # The internal Xray ID of the created test execution
1890                    'jira': {
1891                        'key': str       # The JIRA issue key of the created test execution
1892                    }
1893                }
1894        Example:
1895            >>> client = XrayGraphQL()
1896            >>> test_execution = client.create_test_execution(
1897            ...     test_issue_keys=["TEST-123", "TEST-124"],
1898            ...     project_key="TEST",
1899            ...     summary="Sprint 1 Regression Tests"
1900            ... )
1901            >>> print(test_execution)
1902            {'issueId': '10001', 'jira': {'key': 'TEST-125'}}
1903        Note:
1904            - Invalid test issue keys are logged as warnings but don't prevent execution creation
1905            - At least one valid test issue key is required
1906            - The method validates each test issue key before creating the execution
1907            - Project key is automatically derived from the first test issue key if not provided
1908        """
1909        try:
1910            invalid_keys = []
1911            test_issue_ids = []
1912            for key in test_issue_keys:
1913                test_issue_id = self.get_issue_id_from_jira_id(key, "test")
1914                if test_issue_id:
1915                    test_issue_ids.append(test_issue_id)
1916                else:
1917                    invalid_keys.append(key)
1918            if len(test_issue_ids) == 0:
1919                logger.error(f"No valid test issue keys provided: {invalid_keys}")
1920                return None
1921            if len(invalid_keys) > 0:
1922                logger.warning(f"Invalid test issue keys: {invalid_keys}")
1923            if not project_key:
1924                project_key = test_issue_keys[0].split("-")[0]
1925            if not summary:
1926                summary = f"Test Execution for Test Plan {test_issue_keys}"
1927            if not description:
1928                description = f"Test Execution for Test Plan {test_issue_keys}"
1929            mutation = """
1930            mutation CreateTestExecutionForTestPlan(
1931                $testIssueId_list: [String!]!,
1932                $projectKey: String!,
1933                $summary: String!,
1934                $description: String
1935            ) {
1936                createTestExecution(
1937                    testIssueIds: $testIssueId_list,
1938                    jira: {
1939                        fields: {
1940                            project: { key: $projectKey },
1941                            summary: $summary,
1942                            description: $description,
1943                            issuetype: { name: "Test Execution" }
1944                        }
1945                    }
1946                ) {
1947                    testExecution {
1948                        issueId
1949                        jira(fields: ["key"])
1950                    }
1951                    warnings
1952                }
1953            }
1954            """
1955            variables = {
1956                "testIssueId_list": test_issue_ids,
1957                "projectKey": project_key,
1958                "summary": summary,
1959                "description": description
1960            }
1961            data = self._make_graphql_request(mutation, variables)
1962            if not data:
1963                return None
1964            execution_details = data['createTestExecution']['testExecution']
1965            # logger.info(f"Created test execution {execution_details['jira']['key']}")
1966            return execution_details
1967        except Exception as e:
1968            logger.error("Failed to create test execution : {e}")
1969            logger.traceback(e)
1970            return None
1971    
1972    def create_test_execution_from_test_plan(self, test_plan: str) -> Optional[Dict[str, Dict[str, str]]]:
1973        """Creates a test execution from a given test plan and associates all tests from the plan with the execution.
1974        This method performs several operations in sequence:
1975        1. Retrieves all tests from the specified test plan
1976        2. Creates a new test execution with those tests
1977        3. Associates the new test execution with the original test plan
1978        4. Creates test runs for each test in the execution
1979        Parameters
1980        ----------
1981        test_plan : str
1982            The JIRA issue key of the test plan (e.g., "PROJECT-123")
1983        Returns
1984        -------
1985        Optional[Dict[str, Dict[str, str]]]
1986            A dictionary mapping test case keys to their execution details, or None if the operation fails.
1987            The dictionary structure is::
1988                {
1989                    "TEST-123": {                    # Test case JIRA key
1990                        "test_run_id": "12345",      # Unique ID for this test run
1991                        "test_execution_key": "TEST-456",  # JIRA key of the created test execution
1992                        "test_plan_key": "TEST-789"  # Original test plan JIRA key
1993                    },
1994                    "TEST-124": {
1995                        ...
1996                    }
1997                }
1998            Returns None in the following cases:
1999            * Test plan parameter is empty or invalid
2000            * No tests found in the test plan
2001            * Test execution creation fails
2002            * API request fails
2003        Examples
2004        --------
2005        >>> client = XrayGraphQL()
2006        >>> result = client.create_test_execution_from_test_plan("TEST-123")
2007        >>> print(result)
2008        {
2009            "TEST-124": {
2010                "test_run_id": "5f7c3",
2011                "test_execution_key": "TEST-456",
2012                "test_plan_key": "TEST-123"
2013            },
2014            "TEST-125": {
2015                "test_run_id": "5f7c4",
2016                "test_execution_key": "TEST-456",
2017                "test_plan_key": "TEST-123"
2018            }
2019        }
2020        Notes
2021        -----
2022        - The test plan must exist and be accessible in Xray
2023        - All tests in the test plan must be valid and accessible
2024        - The method automatically generates a summary and description for the test execution
2025        - The created test execution is automatically linked back to the original test plan
2026        """
2027        try:
2028            if not test_plan:
2029                logger.error("Test plan is required [ jira key]")
2030                return None
2031            project_key = test_plan.split("-")[0]
2032            summary = f"Test Execution for Test Plan {test_plan}"
2033            retDict = dict()
2034            #Get tests from test plan
2035            tests = self.get_tests_from_test_plan(test_plan)
2036            retDict["tests"] = tests
2037            testIssueId_list = list(tests.values())
2038            # logger.info(f"Tests: {tests}")
2039            if not testIssueId_list:
2040                logger.error(f"No tests found for {test_plan}")
2041                return None
2042            description = f"Test Execution for {len(tests)} Test cases"
2043            # GraphQL mutation to create test execution
2044            query = """
2045                mutation CreateTestExecutionForTestPlan(
2046                    $testIssueId_list: [String!]!,
2047                    $projectKey: String!,
2048                    $summary: String!,
2049                    $description: String
2050                ) {
2051                    createTestExecution(
2052                        testIssueIds: $testIssueId_list,
2053                        jira: {
2054                            fields: {
2055                                project: { key: $projectKey },
2056                                summary: $summary,
2057                                description: $description,
2058                                issuetype: { name: "Test Execution" }
2059                            }
2060                        }
2061                    ) {
2062                        testExecution {
2063                            issueId
2064                            jira(fields: ["key"])
2065                            testRuns(limit: 100) {
2066                                results {
2067                                    id
2068                                    test {
2069                                        issueId
2070                                        jira(fields: ["key"])
2071                                    }
2072                                }
2073                            }
2074                        }
2075                        warnings
2076                    }
2077                }
2078            """
2079            variables = {
2080                "testIssueId_list": testIssueId_list,
2081                "projectKey": project_key,
2082                "summary": summary,
2083                "description": description or f"Test execution for total of {len(testIssueId_list)} test cases"
2084            }
2085            data = self._make_graphql_request(query, variables)
2086            if not data:
2087                return None
2088            test_exec_key = data['createTestExecution']['testExecution']['jira']['key']
2089            test_exec_id = data['createTestExecution']['testExecution']['issueId']
2090            #Add Test execution to test plan
2091            test_exec_dict= self.add_test_execution_to_test_plan(test_plan, test_exec_key)
2092            #Get test runs for test execution
2093            test_runs = data['createTestExecution']['testExecution']['testRuns']['results']
2094            test_run_dict = dict()
2095            for test_run in test_runs:
2096                test_run_dict[test_run['test']['jira']['key']] = dict()
2097                test_run_dict[test_run['test']['jira']['key']]['test_run_id'] = test_run['id']
2098                # test_run_dict[test_run['test']['jira']['key']]['test_issue_id'] = test_run['test']['issueId']
2099                test_run_dict[test_run['test']['jira']['key']]['test_execution_key'] = test_exec_key
2100                # test_run_dict[test_run['test']['jira']['key']]['test_execution_id'] = test_exec_id
2101                test_run_dict[test_run['test']['jira']['key']]['test_plan_key'] = test_plan
2102            return test_run_dict
2103        except requests.exceptions.RequestException as e:
2104            logger.error(f"Error creating test execution: {e}")
2105            logger.traceback(e)
2106        return None
2107    
2108    def update_test_run_status(self, test_run_id: str, test_run_status: str) -> bool:
2109        """
2110        Update the status of a specific test run in Xray using the GraphQL API.
2111        This method allows updating the execution status of a test run identified by its ID.
2112        The status can be changed to reflect the current state of the test execution
2113        (e.g., "PASS", "FAIL", "TODO", etc.).
2114        Args:
2115            test_run_id (str): The unique identifier of the test run to update.
2116                This is the internal Xray ID for the test run, not the Jira issue key.
2117            test_run_status (str): The new status to set for the test run.
2118                Common values include: "PASS", "FAIL", "TODO", "EXECUTING", etc.
2119        Returns:
2120            bool: True if the status update was successful, False otherwise.
2121                Returns None if an error occurs during the API request.
2122        Example:
2123            >>> client = XrayGraphQL()
2124            >>> test_run_id = client.get_test_run_status("TEST-CASE-KEY", "TEST-EXEC-KEY")
2125            >>> success = client.update_test_run_status(test_run_id, "PASS")
2126            >>> print(success)
2127            True
2128        Note:
2129            - The test run ID must be valid and accessible with current authentication
2130            - The status value should be one of the valid status values configured in your Xray instance
2131            - Failed updates are logged as errors with details about the failure
2132        Raises:
2133            Exception: If there is an error making the GraphQL request or processing the response.
2134                The exception is caught and logged, and the method returns None.
2135        """
2136        try:
2137            query = """
2138            mutation UpdateTestRunStatus($testRunId: String!, $status: String!) {
2139                updateTestRunStatus(
2140                    id: $testRunId, 
2141                    status: $status 
2142                ) 
2143            }                       
2144            """
2145            variables = {
2146                "testRunId": test_run_id,
2147                "status": test_run_status
2148            }
2149            data = self._make_graphql_request(query, variables)
2150            if not data:
2151                logger.error(f"Failed to get test run status for test {data}")
2152                return None
2153            # logger.info(f"Test run status updated: {data}")
2154            return data['updateTestRunStatus']
2155        except Exception as e:
2156            logger.error(f"Error updating test run status: {str(e)}")
2157            logger.traceback(e)
2158            return None
2159    
2160    def update_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2161        """
2162        Update the comment of a specific test run in Xray using the GraphQL API.
2163        This method allows adding or updating the comment associated with a test run
2164        identified by its ID. The comment can provide additional context, test results,
2165        or any other relevant information about the test execution.
2166        Args:
2167            test_run_id (str): The unique identifier of the test run to update.
2168                This is the internal Xray ID for the test run, not the Jira issue key.
2169            test_run_comment (str): The new comment text to set for the test run.
2170                This will replace any existing comment on the test run.
2171        Returns:
2172            bool: True if the comment update was successful, False otherwise.
2173                Returns None if an error occurs during the API request.
2174        Example:
2175            >>> client = XrayGraphQL()
2176            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2177            >>> success = client.update_test_run_comment(
2178            ...     test_run_id,
2179            ...     "Test passed with performance within expected range"
2180            ... )
2181            >>> print(success)
2182            True
2183        Note:
2184            - The test run ID must be valid and accessible with current authentication
2185            - The comment can include any text content, including newlines and special characters
2186            - Failed updates are logged as errors with details about the failure
2187            - This method will overwrite any existing comment on the test run
2188        Raises:
2189            Exception: If there is an error making the GraphQL request or processing the response.
2190                The exception is caught and logged, and the method returns None.
2191        """
2192        try:
2193            query = """
2194            mutation UpdateTestRunStatus($testRunId: String!, $comment: String!) {
2195                updateTestRunComment(
2196                    id: $testRunId, 
2197                    comment: $comment 
2198                ) 
2199            }                       
2200            """
2201            variables = {
2202                "testRunId": test_run_id,
2203                "comment": test_run_comment
2204            }
2205            data = self._make_graphql_request(query, variables)
2206            if not data:
2207                logger.error(f"Failed to get test run comment for test {data}")
2208                return None
2209            # jprint(data)
2210            return data['updateTestRunComment']
2211        except Exception as e:
2212            logger.error(f"Error updating test run comment: {str(e)}")
2213            logger.traceback(e)
2214            return None
2215    
2216    def add_evidence_to_test_run(self, test_run_id: str, evidence_path: str) -> bool:
2217        """Add evidence (attachments) to a test run in Xray.
2218        This method allows attaching files as evidence to a specific test run. The file is
2219        read, converted to base64, and uploaded to Xray with appropriate MIME type detection.
2220        Parameters
2221        ----------
2222        test_run_id : str
2223            The unique identifier of the test run to add evidence to
2224        evidence_path : str
2225            The local file system path to the evidence file to be attached
2226        Returns
2227        -------
2228        bool
2229            True if the evidence was successfully added, None if the operation failed.
2230            Returns None in the following cases:
2231            - Test run ID is not provided
2232            - Evidence path is not provided
2233            - Evidence file does not exist
2234            - GraphQL request fails
2235            - Any other error occurs during processing
2236        Examples
2237        --------
2238        >>> client = XrayGraphQL()
2239        >>> success = client.add_evidence_to_test_run(
2240        ...     test_run_id="10001",
2241        ...     evidence_path="/path/to/screenshot.png"
2242        ... )
2243        >>> print(success)
2244        True
2245        Notes
2246        -----
2247        - The evidence file must exist and be accessible
2248        - The file is automatically converted to base64 for upload
2249        - MIME type is automatically detected, defaults to "text/plain" if detection fails
2250        - The method supports various file types (images, documents, logs, etc.)
2251        - Failed operations are logged with appropriate error messages
2252        """
2253        try:
2254            if not test_run_id:
2255                logger.error("Test run ID is required")
2256                return None
2257            if not evidence_path:
2258                logger.error("Evidence path is required")
2259                return None
2260            if not os.path.exists(evidence_path):
2261                logger.error(f"Evidence file not found: {evidence_path}")
2262                return None
2263            #if file exists then read the file in base64
2264            evidence_base64 = None
2265            mime_type = None
2266            filename = os.path.basename(evidence_path)
2267            with open(evidence_path, "rb") as file:
2268                evidence_data = file.read()
2269                evidence_base64 = base64.b64encode(evidence_data).decode('utf-8')
2270                mime_type = mimetypes.guess_type(evidence_path)[0]
2271                logger.info(f"For loop -- Mime type: {mime_type}")
2272                if not mime_type:
2273                    mime_type = "text/plain"
2274            query = """
2275            mutation AddEvidenceToTestRun($testRunId: String!, $filename: String!, $mimeType: String!, $evidenceBase64: String!) {
2276                addEvidenceToTestRun(
2277                    id: $testRunId, 
2278                    evidence: [
2279                        {
2280                            filename : $filename,
2281                            mimeType : $mimeType,
2282                            data : $evidenceBase64
2283                        }
2284                    ]
2285                ) {
2286                    addedEvidence
2287                    warnings
2288                }
2289            }
2290            """
2291            variables = {
2292                "testRunId": test_run_id,
2293                "filename": filename,
2294                "mimeType": mime_type,
2295                "evidenceBase64": evidence_base64
2296            }
2297            data = self._make_graphql_request(query, variables) 
2298            if not data:
2299                logger.error(f"Failed to add evidence to test run: {data}")
2300                return None
2301            return data['addEvidenceToTestRun'] 
2302        except Exception as e:
2303            logger.error(f"Error adding evidence to test run: {str(e)}")
2304            logger.traceback(e)
2305            return None
2306    
2307    def create_defect_from_test_run(self, test_run_id: str, project_key: str, parent_issue_key: str, defect_summary: str, defect_description: str) -> Optional[Dict]:
2308        """Create a defect from a test run and link it to the test run in Xray.
2309        This method performs two main operations:
2310        1. Creates a new defect in JIRA with the specified summary and description
2311        2. Links the created defect to the specified test run in Xray
2312        Parameters
2313        ----------
2314        test_run_id : str
2315            The ID of the test run to create defect from
2316        project_key : str
2317            The JIRA project key where the defect should be created.
2318            If not provided, defaults to "EAGVAL"
2319        parent_issue_key : str
2320            The JIRA key of the parent issue to link the defect to
2321        defect_summary : str
2322            Summary/title of the defect.
2323            If not provided, defaults to "Please provide a summary for the defect"
2324        defect_description : str
2325            Description of the defect.
2326            If not provided, defaults to "Please provide a description for the defect"
2327        Returns
2328        -------
2329        Optional[Dict]
2330            Response data from the GraphQL API if successful, None if failed.
2331            The response includes:
2332            - addedDefects: List of added defects
2333            - warnings: Any warnings from the operation
2334        Examples
2335        --------
2336        >>> client = XrayGraphQL()
2337        >>> result = client.create_defect_from_test_run(
2338        ...     test_run_id="10001",
2339        ...     project_key="PROJ",
2340        ...     parent_issue_key="PROJ-456",
2341        ...     defect_summary="Test failure in login flow",
2342        ...     defect_description="The login button is not responding to clicks"
2343        ... )
2344        >>> print(result)
2345        {
2346            'addedDefects': ['PROJ-123'],
2347            'warnings': []
2348        }
2349        Notes
2350        -----
2351        - The project_key will be split on '-' and only the first part will be used
2352        - The defect will be created with issue type 'Bug'
2353        - The method handles missing parameters with default values
2354        - The parent issue must exist and be accessible to create the defect
2355        """
2356        try:
2357            if not project_key:
2358                project_key = "EAGVAL"
2359            if not defect_summary:
2360                defect_summary = "Please provide a summary for the defect"
2361            if not defect_description:
2362                defect_description = "Please provide a description for the defect"
2363            project_key = project_key.split("-")[0]
2364            # Fix: Correct parameter order for create_issue
2365            defect_key, defect_id = self.create_issue(
2366                project_key=project_key,
2367                parent_issue_key=parent_issue_key,
2368                summary=defect_summary,
2369                description=defect_description,
2370                issue_type='Bug'
2371            )
2372            if not defect_key:
2373                logger.error("Failed to create defect issue")
2374                return None
2375            # Then add the defect to the test run
2376            add_defect_mutation = """
2377            mutation AddDefectsToTestRun(
2378                $testRunId: String!,
2379                $defectKey: String!
2380            ) {
2381                addDefectsToTestRun( id: $testRunId, issues: [$defectKey]) {
2382                    addedDefects
2383                    warnings
2384                }
2385            }
2386            """
2387            variables = {
2388                "testRunId": test_run_id,
2389                "defectKey": defect_key
2390            }
2391            data = None
2392            retry_count = 0
2393            while retry_count < 3:
2394                data = self._make_graphql_request(add_defect_mutation, variables)
2395                if not data:
2396                    logger.error(f"Failed to add defect [{defect_key}] to test run - {test_run_id}.. retrying... {retry_count}")
2397                    retry_count += 1
2398                    time.sleep(1)
2399                else:
2400                    break
2401            return data
2402        except Exception as e:
2403            logger.error(f"Error creating defect from test run: {str(e)}")
2404            logger.traceback(e)
2405            return None
2406    
2407    def get_test_run_comment(self, test_run_id: str) -> Optional[str]:
2408        """
2409        Retrieve the comment of a specific test run from Xray using the GraphQL API.
2410        This method allows retrieving the comment associated with a test run
2411        identified by its ID. The comment can provide additional context, test results,
2412        or any other relevant information about the test execution.
2413        Args:
2414            test_run_id (str): The unique identifier of the test run to retrieve comment from.
2415                This is the internal Xray ID for the test run, not the Jira issue key.
2416        Returns:
2417            Optional[str]: The comment text of the test run if successful, None if:
2418                - The test run ID is not found
2419                - The GraphQL request fails
2420                - No comment exists for the test run
2421                - Any other error occurs during the API request
2422        Example:
2423            >>> client = XrayGraphQL()
2424            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2425            >>> comment = client.get_test_run_comment(test_run_id)
2426            >>> print(comment)
2427            "Test passed with performance within expected range"
2428        Note:
2429            - The test run ID must be valid and accessible with current authentication
2430            - If no comment exists for the test run, the method will return None
2431            - Failed requests are logged as errors with details about the failure
2432            - The method returns the raw comment text as stored in Xray
2433        Raises:
2434            Exception: If there is an error making the GraphQL request or processing the response.
2435                The exception is caught and logged, and the method returns None.
2436        """
2437        try:
2438            # Try the direct ID approach first
2439            query = """
2440            query GetTestRunComment($testRunId: String!) {
2441                getTestRunById(id: $testRunId) {
2442                    id
2443                    comment
2444                    status {
2445                        name
2446                    }
2447                }
2448            }                       
2449            """
2450            variables = {
2451                "testRunId": test_run_id
2452            }
2453            data = self._make_graphql_request(query, variables)
2454            jprint(data)
2455            if not data:
2456                logger.error(f"Failed to get test run comment for test run {test_run_id}")
2457                return None
2458            test_run = data.get('getTestRunById', {})
2459            if not test_run:
2460                logger.warning(f"No test run found with ID {test_run_id}")
2461                return None
2462            comment = test_run.get('comment')
2463            return comment
2464        except Exception as e:
2465            logger.error(f"Error getting test run comment: {str(e)}")
2466            logger.traceback(e)
2467            return None
2468    
2469    def append_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2470        """
2471        Append the comment of a specific test run in Xray using the GraphQL API.
2472        This method allows appending the comment associated with a test run
2473        identified by its ID. The comment can provide additional context, test results,
2474        or any other relevant information about the test execution.
2475        Args:
2476            test_run_id (str): The unique identifier of the test run to update.
2477                This is the internal Xray ID for the test run, not the Jira issue key.
2478            test_run_comment (str): The comment text to append to the test run.
2479                This will be added to any existing comment on the test run with proper formatting.
2480        Returns:
2481            bool: True if the comment update was successful, False otherwise.
2482                Returns None if an error occurs during the API request.
2483        Example:
2484            >>> client = XrayGraphQL()
2485            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2486            >>> success = client.append_test_run_comment(
2487            ...     test_run_id,
2488            ...     "Test passed with performance within expected range"
2489            ... )
2490            >>> print(success)
2491            True
2492        Note:
2493            - The test run ID must be valid and accessible with current authentication
2494            - The comment can include any text content, including newlines and special characters
2495            - Failed updates are logged as errors with details about the failure
2496            - This method will append to existing comments with proper line breaks
2497            - If no existing comment exists, the new comment will be set as the initial comment
2498        Raises:
2499            Exception: If there is an error making the GraphQL request or processing the response.
2500                The exception is caught and logged, and the method returns None.
2501        """
2502        try:
2503            # Get existing comment
2504            existing_comment = self.get_test_run_comment(test_run_id)
2505            # Prepare the combined comment with proper formatting
2506            if existing_comment:
2507                # If there's an existing comment, append with double newline for proper separation
2508                combined_comment = f"{existing_comment}\n{test_run_comment}"
2509            else:
2510                # If no existing comment, use the new comment as is
2511                combined_comment = test_run_comment
2512                logger.debug(f"No existing comment found for test run {test_run_id}, setting initial comment")
2513            query = """
2514            mutation UpdateTestRunComment($testRunId: String!, $comment: String!) {
2515                updateTestRunComment(
2516                    id: $testRunId, 
2517                    comment: $comment 
2518                ) 
2519            }                       
2520            """
2521            variables = {
2522                "testRunId": test_run_id,
2523                "comment": combined_comment
2524            }
2525            data = self._make_graphql_request(query, variables)
2526            if not data:
2527                logger.error(f"Failed to update test run comment for test run {test_run_id}")
2528                return None
2529            return data['updateTestRunComment']
2530        except Exception as e:
2531            logger.error(f"Error updating test run comment: {str(e)}")
2532            logger.traceback(e)
2533            return None
2534
2535    def download_attachment(self, jira_key: str, file_extension: str) -> Optional[Dict]:
2536        '''
2537        Download a JIRA attachment by its ID.
2538        '''
2539        try:
2540            response = self.make_jira_request(jira_key, 'GET')
2541            
2542            if not response or 'fields' not in response:
2543                logger.error(f"Error: Could not retrieve issue {jira_key}")
2544                return None
2545            
2546            # Find attachment by filename
2547            attachments = response.get('fields', {}).get('attachment', [])
2548            target_attachment = [att for att in attachments if att.get('filename').endswith(file_extension)]
2549            if not target_attachment:
2550                logger.error(f"No attachment found for {jira_key} with extension {file_extension}")
2551                return None
2552            
2553            combined_attachment = []
2554            for attachment in target_attachment:
2555                attachment_id = attachment.get('id')
2556                mime_type = attachment.get('mimeType', '')
2557                combined_attachment.append(self.download_jira_attachment_by_id(attachment_id, mime_type))
2558            
2559            return combined_attachment
2560        except Exception as e:
2561            logger.error(f"Error downloading JIRA attachment: {str(e)}")
2562            logger.traceback(e)
2563            return None

A comprehensive client for interacting with Xray Cloud's GraphQL API. This class extends JiraHandler to provide specialized methods for interacting with Xray Cloud's GraphQL API for test management. It handles authentication, test plans, test executions, test runs, defects, evidence, and other Xray-related operations through GraphQL queries and mutations.

Inherits

JiraHandler Base class providing JIRA client functionality and issue management

Attributes

client_id : str The client ID for Xray authentication client_secret : str The client secret for Xray authentication xray_base_url : str Base URL for Xray Cloud API (defaults to 'https://us.xray.cloud.getxray.app') logger : Logger Logger instance for debugging and error tracking token : str Authentication token obtained from Xray

Methods

Authentication & Setup

__init__() Initialize XrayGraphQL client with authentication and configuration settings. _get_auth_token() -> Optional[str] Authenticate with Xray Cloud API and obtain an authentication token. _make_graphql_request(query: str, variables: Dict) -> Optional[Dict] Makes a GraphQL request to the Xray API with proper authentication. _parse_table(table_str: str) -> Dict[str, Union[List[int], List[float]]] Parse a string representation of a table into a dictionary of numeric values.

Issue ID Management

get_issue_id_from_jira_id(issue_key: str, issue_type: str) -> Optional[str] Retrieves the internal Xray issue ID for a given JIRA issue key and type.

Test Plan Operations

get_tests_from_test_plan(test_plan: str) -> Optional[Dict[str, str]] Retrieves all tests associated with a given test plan. get_test_plan_data(test_plan: str) -> Optional[Dict[str, Union[List[int], List[float]]]] Retrieves and parses tabular data from a test plan's description.

Test Set Operations

get_tests_from_test_set(test_set: str) -> Optional[Dict[str, str]] Retrieves all tests associated with a given test set. filter_test_set_by_test_case(test_key: str) -> Optional[Dict[str, str]] Retrieves all test sets containing a specific test case. filter_tags_by_test_case(test_key: str) -> Optional[List[str]] Extracts and filters tags from test sets associated with a test case.

Test Execution Operations

get_tests_from_test_execution(test_execution: str) -> Optional[Dict[str, str]] Retrieves all tests associated with a given test execution. get_test_execution(test_execution: str) -> Optional[Dict] Retrieve detailed information about a test execution from Xray. create_test_execution(test_issue_keys: List[str], project_key: Optional[str], summary: Optional[str], description: Optional[str]) -> Optional[Dict] Creates a new test execution with specified test cases. create_test_execution_from_test_plan(test_plan: str) -> Optional[Dict[str, Dict[str, str]]] Creates a test execution from a given test plan with all associated tests. add_test_execution_to_test_plan(test_plan: str, test_execution: str) -> Optional[Dict] Add a test execution to an existing test plan in Xray.

Test Run Operations

get_test_runstatus(test_case: str, test_execution: str) -> Optional[Tuple[str, str]] Retrieves the status of a test run for a specific test case. get_test_run_by_id(test_case_id: str, test_execution_id: str) -> Optional[Tuple[str, str]] Retrieves the test run ID and status using internal Xray IDs. update_test_run_status(test_run_id: str, test_run_status: str) -> bool Updates the status of a specific test run. update_test_run_comment(test_run_id: str, test_run_comment: str) -> bool Updates the comment of a specific test run. get_test_run_comment(test_run_id: str) -> Optional[str] Retrieve the comment of a specific test run from Xray. append_test_run_comment(test_run_id: str, test_run_comment: str) -> bool Append a comment to an existing test run comment.

Evidence & Defect Management

add_evidence_to_test_run(test_run_id: str, evidence_path: str) -> bool Add evidence (attachments) to a test run in Xray. create_defect_from_test_run(test_run_id: str, project_key: str, parent_issue_key: str, defect_summary: str, defect_description: str) -> Optional[Dict] Create a defect from a test run and link it to the test run in Xray.

Examples

>>> client = XrayGraphQL()
>>> test_plan_tests = client.get_tests_from_test_plan("TEST-123")
>>> print(test_plan_tests)
{'TEST-124': '10001', 'TEST-125': '10002'}
>>> test_execution = client.create_test_execution_from_test_plan("TEST-123")
>>> print(test_execution)
{
    'TEST-124': {
        'test_run_id': '5f7c3',
        'test_execution_key': 'TEST-456',
        'test_plan_key': 'TEST-123'
    }
}
>>> # Update test run status
>>> success = client.update_test_run_status("test_run_id", "PASS")
>>> print(success)
True
>>> # Add evidence to test run
>>> evidence_added = client.add_evidence_to_test_run("test_run_id", "/path/to/screenshot.png")
>>> print(evidence_added)
True
<h2 id="notes">Notes</h2>
  • Requires valid Xray Cloud credentials (client_id and client_secret)
  • Uses GraphQL for all API interactions
  • Implements automatic token refresh
  • Handles rate limiting and retries
  • All methods include comprehensive error handling and logging
  • Returns None for failed operations instead of raising exceptions
  • Supports various file types for evidence attachments
  • Integrates with JIRA for defect creation and issue management
  • Provides both synchronous and asynchronous operation patterns
  • Includes retry logic for transient failures
XrayGraphQL()
764    def __init__(self):
765        """Initialize XrayGraphQL client with authentication and configuration settings.    
766        This constructor sets up the XrayGraphQL client by:
767        1. Loading environment variables from .env file
768        2. Reading required environment variables for authentication
769        3. Configuring the base URL for Xray Cloud
770        4. Obtaining an authentication token
771        Required Environment Variables
772        ----------------------------
773        XRAY_CLIENT_ID : str
774            Client ID for Xray authentication
775        XRAY_CLIENT_SECRET : str
776            Client secret for Xray authentication
777        XRAY_BASE_URL : str, optional
778            Base URL for Xray Cloud API. Defaults to 'https://us.xray.cloud.getxray.app'
779        Attributes
780        ----------
781        client_id : str
782            Xray client ID from environment
783        client_secret : str
784            Xray client secret from environment
785        xray_base_url : str
786            Base URL for Xray Cloud API
787        logger : Logger
788            Logger instance for debugging and error tracking
789        token : str
790            Authentication token obtained from Xray
791        Raises
792        ------
793        Exception
794            If authentication fails or required environment variables are missing
795        """
796        super().__init__()
797        try:
798            # Load environment variables from .env file
799            self.client_id = os.getenv('XRAY_CLIENT_ID')
800            self.client_secret = os.getenv('XRAY_CLIENT_SECRET')
801            self.xray_base_url = os.getenv('XRAY_BASE_URL', 'https://us.xray.cloud.getxray.app')
802            self.logger = logger
803            # Validate required environment variables
804            if not self.client_id or self.client_id == '<CLIENT_ID>':
805                raise ValueError("XRAY_CLIENT_ID environment variable is required")
806            if not self.client_secret or self.client_secret == '<CLIENT_SECRET>':
807                raise ValueError("XRAY_CLIENT_SECRET environment variable is required")
808            # Get authentication token
809            self.token = self._get_auth_token()
810            if not self.token:
811                logger.error("Failed to authenticate with Xray GraphQL")
812                raise Exception("Failed to initialize XrayGraphQL: No authentication token")
813        except Exception as e:
814            logger.error(f"Error initializing XrayGraphQL: {e}")
815            logger.traceback(e)
816            raise e

Initialize XrayGraphQL client with authentication and configuration settings.
This constructor sets up the XrayGraphQL client by:

  1. Loading environment variables from .env file
  2. Reading required environment variables for authentication
  3. Configuring the base URL for Xray Cloud
  4. Obtaining an authentication token

Required Environment Variables

XRAY_CLIENT_ID : str Client ID for Xray authentication XRAY_CLIENT_SECRET : str Client secret for Xray authentication XRAY_BASE_URL : str, optional Base URL for Xray Cloud API. Defaults to 'https://us.xray.cloud.getxray.app'

Attributes

client_id : str Xray client ID from environment client_secret : str Xray client secret from environment xray_base_url : str Base URL for Xray Cloud API logger : Logger Logger instance for debugging and error tracking token : str Authentication token obtained from Xray

Raises

Exception If authentication fails or required environment variables are missing

def get_issue_id_from_jira_id(self, issue_key: str, issue_type: str) -> Optional[str]:
 997    def get_issue_id_from_jira_id(self, issue_key: str, issue_type: str) -> Optional[str]:
 998        """
 999        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
1000        This method queries the Xray GraphQL API to find the internal issue ID corresponding
1001        to a JIRA issue key. It supports different types of Xray artifacts including test plans,
1002        test executions, test sets, and tests.
1003        Args:
1004            issue_key (str): The JIRA issue key (e.g., "PROJECT-123")
1005            issue_type (str): The type of Xray artifact. Supported values are:
1006                - "plan" or contains "plan": For Test Plans
1007                - "exec" or contains "exec": For Test Executions
1008                - "set" or contains "set": For Test Sets
1009                - "test" or contains "test": For Tests
1010                If not provided, defaults to "plan"
1011        Returns:
1012            Optional[str]: The internal Xray issue ID if found, None if:
1013                - The issue key doesn't exist
1014                - The GraphQL request fails
1015                - Any other error occurs during processing
1016        Examples:
1017            >>> client.get_issue_id_from_jira_id("TEST-123", "plan")
1018            '10000'
1019            >>> client.get_issue_id_from_jira_id("TEST-456", "test")
1020            '10001'
1021            >>> client.get_issue_id_from_jira_id("INVALID-789", "plan")
1022            None
1023        Note:
1024            The method performs a case-insensitive comparison when matching issue keys.
1025            The project key is extracted from the issue_key (text before the hyphen)
1026            to filter results by project.
1027        """
1028        try:
1029            parse_project = issue_key.split("-")[0]
1030            function_name = "getTestPlans"
1031            if not issue_type:
1032                issue_type = "plan"
1033            if "plan" in issue_type.lower():
1034                function_name = "getTestPlans"
1035                query = """
1036                    query GetIds($limit: Int!, $jql: String!) {    
1037                        getTestPlans(limit: $limit, jql:$jql) {
1038                            results {
1039                                issueId
1040                                jira(fields: ["key"])
1041                            }
1042                        }
1043                    }
1044                    """
1045            if "exec" in issue_type.lower():
1046                function_name = "getTestExecutions"
1047                query = """
1048                    query GetIds($limit: Int!, $jql: String!) {    
1049                        getTestExecutions(limit: $limit, jql:$jql) {
1050                            results {
1051                                issueId
1052                                jira(fields: ["key"])
1053                            }
1054                        }
1055                    }
1056                    """
1057            if "set" in issue_type.lower():
1058                function_name = "getTestSets"
1059                query = """
1060                    query GetIds($limit: Int!, $jql: String!) {    
1061                        getTestSets(limit: $limit, jql:$jql) {
1062                            results {
1063                                issueId
1064                                jira(fields: ["key"])
1065                            }
1066                        }
1067                    }
1068                    """
1069            if "test" in issue_type.lower():
1070                function_name = "getTests"
1071                query = """
1072                    query GetIds($limit: Int!, $jql: String!) {    
1073                        getTests(limit: $limit, jql:$jql) {
1074                            results {
1075                                issueId
1076                                jira(fields: ["key"])
1077                            }
1078                        }
1079                    }
1080                    """
1081            variables = {
1082                "limit": 10,
1083                "jql":  f"project = '{parse_project}' AND key = '{issue_key}'"
1084            }
1085            data = self._make_graphql_request(query, variables)
1086            if not data:
1087                logger.error(f"Failed to get issue ID for {issue_key}")
1088                return None
1089            for issue in data[function_name]['results']:
1090                if str(issue['jira']['key']).lower() == issue_key.lower():
1091                    return issue['issueId']
1092            return None
1093        except Exception as e:
1094            logger.error(f"Failed to get issue ID for {issue_key}")
1095            logger.traceback(e)
1096            return None

Retrieves the internal Xray issue ID for a given JIRA issue key and type. This method queries the Xray GraphQL API to find the internal issue ID corresponding to a JIRA issue key. It supports different types of Xray artifacts including test plans, test executions, test sets, and tests. Args: issue_key (str): The JIRA issue key (e.g., "PROJECT-123") issue_type (str): The type of Xray artifact. Supported values are: - "plan" or contains "plan": For Test Plans - "exec" or contains "exec": For Test Executions - "set" or contains "set": For Test Sets - "test" or contains "test": For Tests If not provided, defaults to "plan" Returns: Optional[str]: The internal Xray issue ID if found, None if: - The issue key doesn't exist - The GraphQL request fails - Any other error occurs during processing Examples:

client.get_issue_id_from_jira_id("TEST-123", "plan") '10000' client.get_issue_id_from_jira_id("TEST-456", "test") '10001' client.get_issue_id_from_jira_id("INVALID-789", "plan") None Note: The method performs a case-insensitive comparison when matching issue keys. The project key is extracted from the issue_key (text before the hyphen) to filter results by project.

def get_test_details(self, issue_key: str, issue_type: str) -> Optional[str]:
1098    def get_test_details(self, issue_key: str, issue_type: str) -> Optional[str]:
1099        """
1100        Retrieves the internal Xray issue ID for a given JIRA issue key and type.
1101        This method queries the Xray GraphQL API to find the internal issue ID corresponding
1102        to a JIRA issue key. It supports different types of Xray artifacts including test plans,
1103        test executions, test sets, and tests.
1104        Args:
1105            issue_key (str): The JIRA issue key (e.g., "PROJECT-123")
1106            issue_type (str): The type of Xray artifact. Supported values are:
1107                - "plan" or contains "plan": For Test Plans
1108                - "exec" or contains "exec": For Test Executions
1109                - "set" or contains "set": For Test Sets
1110                - "test" or contains "test": For Tests
1111                If not provided, defaults to "plan"
1112        Returns:
1113            Optional[str]: The internal Xray issue ID if found, None if:
1114                - The issue key doesn't exist
1115                - The GraphQL request fails
1116                - Any other error occurs during processing
1117        Examples:
1118            >>> client.get_issue_id_from_jira_id("TEST-123", "plan")
1119            '10000'
1120            >>> client.get_issue_id_from_jira_id("TEST-456", "test")
1121            '10001'
1122            >>> client.get_issue_id_from_jira_id("INVALID-789", "plan")
1123            None
1124        Note:
1125            The method performs a case-insensitive comparison when matching issue keys.
1126            The project key is extracted from the issue_key (text before the hyphen)
1127            to filter results by project.
1128        """
1129        try:
1130            parse_project = issue_key.split("-")[0]
1131            function_name = "getTestPlans"
1132            if not issue_type:
1133                issue_type = "plan"
1134            if "plan" in issue_type.lower():
1135                function_name = "getTestPlans"
1136                jira_fields = [
1137                    "key", "summary", "description", "assignee", 
1138                    "status", "priority", "labels", "created", 
1139                    "updated", "dueDate", "components", "versions", 
1140                    "attachments", "comments"
1141                ]
1142                query = """
1143                    query GetDetails($limit: Int!, $jql: String!) {    
1144                        getTestPlans(limit: $limit, jql:$jql) {
1145                            results {
1146                                issueId
1147                                jira(fields: ["key"])
1148                            }
1149                        }
1150                    }
1151                    """
1152            if "exec" in issue_type.lower():
1153                function_name = "getTestExecutions"
1154                jira_fields = [
1155                    "key", "summary", "description", "assignee", 
1156                    "status", "priority", "labels", "created", 
1157                    "updated", "dueDate", "components", "versions", 
1158                    "attachments", "comments"
1159                ]
1160                query = """
1161                    query GetDetails($limit: Int!, $jql: String!) {    
1162                        getTestExecutions(limit: $limit, jql:$jql) {
1163                            results {
1164                                issueId
1165                                jira(fields: ["key"])
1166                            }
1167                        }
1168                    }
1169                    """
1170            if "set" in issue_type.lower():
1171                function_name = "getTestSets"
1172                jira_fields = [
1173                    "key", "summary", "description", "assignee", 
1174                    "status", "priority", "labels", "created", 
1175                    "updated", "dueDate", "components", "versions", 
1176                    "attachments", "comments"
1177                ]
1178                query = """
1179                    query GetDetails($limit: Int!, $jql: String!) {    
1180                        getTestSets(limit: $limit, jql:$jql) {
1181                            results {
1182                                issueId
1183                                jira(fields: ["key"])
1184                            }
1185                        }
1186                    }
1187                    """
1188            if "test" in issue_type.lower():
1189                function_name = "getTests"
1190                jira_fields = [
1191                    "key", "summary", "description", "assignee", 
1192                    "status", "priority", "labels", "created", 
1193                    "updated", "dueDate", "components", "versions", 
1194                    "attachments", "comments"
1195                ]
1196                query = """
1197                    query GetDetails($limit: Int!, $jql: String!, $jiraFields: [String!]!) {    
1198                        getTests(limit: $limit, jql:$jql) {
1199                            results {
1200                                issueId
1201                                jira(fields: $jiraFields)
1202                                steps {
1203                                    id
1204                                    action
1205                                    result
1206                                    attachments {
1207                                    id
1208                                    filename
1209                                    storedInJira
1210                                    downloadLink
1211                                    }
1212                                }
1213                                
1214                            }
1215                        }
1216                    }
1217                    """
1218            variables = {
1219                "limit": 10,
1220                "jql": f"project = '{parse_project}' AND key = '{issue_key}'",
1221                "jiraFields": jira_fields
1222            }
1223            data = self._make_graphql_request(query, variables)
1224            if not data:
1225                logger.error(f"Failed to get issue ID for {issue_key}")
1226                return None
1227            for issue in data[function_name]['results']:
1228                if str(issue['jira']['key']).lower() == issue_key.lower():
1229                    return issue  # This now includes all metadata
1230            return None
1231        except Exception as e:
1232            logger.error(f"Failed to get issue ID for {issue_key}")
1233            logger.traceback(e)
1234            return None

Retrieves the internal Xray issue ID for a given JIRA issue key and type. This method queries the Xray GraphQL API to find the internal issue ID corresponding to a JIRA issue key. It supports different types of Xray artifacts including test plans, test executions, test sets, and tests. Args: issue_key (str): The JIRA issue key (e.g., "PROJECT-123") issue_type (str): The type of Xray artifact. Supported values are: - "plan" or contains "plan": For Test Plans - "exec" or contains "exec": For Test Executions - "set" or contains "set": For Test Sets - "test" or contains "test": For Tests If not provided, defaults to "plan" Returns: Optional[str]: The internal Xray issue ID if found, None if: - The issue key doesn't exist - The GraphQL request fails - Any other error occurs during processing Examples:

client.get_issue_id_from_jira_id("TEST-123", "plan") '10000' client.get_issue_id_from_jira_id("TEST-456", "test") '10001' client.get_issue_id_from_jira_id("INVALID-789", "plan") None Note: The method performs a case-insensitive comparison when matching issue keys. The project key is extracted from the issue_key (text before the hyphen) to filter results by project.

def get_tests_from_test_plan(self, test_plan: str) -> Optional[Dict[str, str]]:
1236    def get_tests_from_test_plan(self, test_plan: str) -> Optional[Dict[str, str]]:
1237        """
1238        Retrieves all tests associated with a given test plan from Xray.
1239        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1240        test plan. It first converts the JIRA test plan key to an internal Xray ID, then uses that
1241        ID to fetch the associated tests.
1242        Args:
1243            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1244        Returns:
1245            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1246                or None if the operation fails. For example:
1247                {
1248                    "PROJECT-124": "10001",
1249                    "PROJECT-125": "10002"
1250                }
1251                Returns None in the following cases:
1252                - Test plan ID cannot be found
1253                - GraphQL request fails
1254                - Any other error occurs during processing
1255        Example:
1256            >>> client = XrayGraphQL()
1257            >>> tests = client.get_tests_from_test_plan("PROJECT-123")
1258            >>> print(tests)
1259            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1260        Note:
1261            - The method is limited to retrieving 99999 tests per test plan
1262            - Test plan must exist in Xray and be accessible with current authentication
1263        """
1264        try:
1265            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1266            if not test_plan_id:
1267                logger.error(f"Failed to get test plan ID for {test_plan}")
1268                return None
1269            query = """
1270            query GetTestPlanTests($testPlanId: String!) {
1271                getTestPlan(issueId: $testPlanId) {
1272                    tests(limit: 99999) {
1273                        results {   
1274                            issueId
1275                            jira(fields: ["key"])
1276                        }
1277                    }
1278                }
1279            }
1280            """
1281            variables = {"testPlanId": test_plan_id}
1282            data = self._make_graphql_request(query, variables)
1283            if not data:
1284                logger.error(f"Failed to get tests for plan {test_plan_id}")
1285                return None
1286            tests = {}
1287            for test in data['getTestPlan']['tests']['results']:
1288                tests[test['jira']['key']] = test['issueId']
1289            return tests
1290        except Exception as e:
1291            logger.error(f"Failed to get tests for plan {test_plan_id}")
1292            logger.traceback(e)
1293            return None

Retrieves all tests associated with a given test plan from Xray. This method queries the Xray GraphQL API to fetch all tests that are part of the specified test plan. It first converts the JIRA test plan key to an internal Xray ID, then uses that ID to fetch the associated tests. Args: test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123") Returns: Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs, or None if the operation fails. For example: { "PROJECT-124": "10001", "PROJECT-125": "10002" } Returns None in the following cases: - Test plan ID cannot be found - GraphQL request fails - Any other error occurs during processing Example:

client = XrayGraphQL() tests = client.get_tests_from_test_plan("PROJECT-123") print(tests) {"PROJECT-124": "10001", "PROJECT-125": "10002"} Note: - The method is limited to retrieving 99999 tests per test plan - Test plan must exist in Xray and be accessible with current authentication

def get_tests_from_test_set(self, test_set: str) -> Optional[Dict[str, str]]:
1295    def get_tests_from_test_set(self, test_set: str) -> Optional[Dict[str, str]]:
1296        """
1297        Retrieves all tests associated with a given test set from Xray.
1298        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1299        test set. It first converts the JIRA test set key to an internal Xray ID, then uses that
1300        ID to fetch the associated tests.
1301        Args:
1302            test_set (str): The JIRA issue key of the test set (e.g., "PROJECT-123")
1303        Returns:
1304            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1305                or None if the operation fails. For example:
1306                {
1307                    "PROJECT-124": "10001",
1308                    "PROJECT-125": "10002"
1309                }
1310                Returns None in the following cases:
1311                - Test set ID cannot be found
1312                - GraphQL request fails
1313                - Any other error occurs during processing
1314        Example:
1315            >>> client = XrayGraphQL()
1316            >>> tests = client.get_tests_from_test_set("PROJECT-123")
1317            >>> print(tests)
1318            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1319        Note:
1320            - The method is limited to retrieving 99999 tests per test set
1321            - Test set must exist in Xray and be accessible with current authentication
1322        """
1323        try:
1324            test_set_id = self.get_issue_id_from_jira_id(test_set, "set")
1325            if not test_set_id:
1326                logger.error(f"Failed to get test set ID for {test_set}")
1327                return None
1328            query = """
1329            query GetTestSetTests($testSetId: String!) {
1330                getTestSet(issueId: $testSetId) {
1331                    tests(limit: 99999) {
1332                        results {   
1333                            issueId
1334                            jira(fields: ["key"])
1335                        }
1336                    }
1337                }
1338            }
1339            """
1340            variables = {"testSetId": test_set_id}
1341            data = self._make_graphql_request(query, variables)
1342            if not data:
1343                logger.error(f"Failed to get tests for set {test_set_id}")
1344                return None
1345            tests = {}
1346            for test in data['getTestSet']['tests']['results']:
1347                tests[test['jira']['key']] = test['issueId']
1348            return tests
1349        except Exception as e:
1350            logger.error(f"Failed to get tests for set {test_set_id}")
1351            logger.traceback(e)
1352            return None

Retrieves all tests associated with a given test set from Xray. This method queries the Xray GraphQL API to fetch all tests that are part of the specified test set. It first converts the JIRA test set key to an internal Xray ID, then uses that ID to fetch the associated tests. Args: test_set (str): The JIRA issue key of the test set (e.g., "PROJECT-123") Returns: Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs, or None if the operation fails. For example: { "PROJECT-124": "10001", "PROJECT-125": "10002" } Returns None in the following cases: - Test set ID cannot be found - GraphQL request fails - Any other error occurs during processing Example:

client = XrayGraphQL() tests = client.get_tests_from_test_set("PROJECT-123") print(tests) {"PROJECT-124": "10001", "PROJECT-125": "10002"} Note: - The method is limited to retrieving 99999 tests per test set - Test set must exist in Xray and be accessible with current authentication

def get_tests_from_test_execution(self, test_execution: str) -> Optional[Dict[str, str]]:
1354    def get_tests_from_test_execution(self, test_execution: str) -> Optional[Dict[str, str]]:
1355        """
1356        Retrieves all tests associated with a given test execution from Xray.
1357        This method queries the Xray GraphQL API to fetch all tests that are part of the specified
1358        test execution. It first converts the JIRA test execution key to an internal Xray ID, then uses that
1359        ID to fetch the associated tests.
1360        Args:
1361            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123")
1362        Returns:
1363            Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs,
1364                or None if the operation fails. For example:
1365                {
1366                    "PROJECT-124": "10001",
1367                    "PROJECT-125": "10002"
1368                }
1369                Returns None in the following cases:
1370                - Test execution ID cannot be found
1371                - GraphQL request fails
1372                - Any other error occurs during processing
1373        Example:
1374            >>> client = XrayGraphQL()
1375            >>> tests = client.get_tests_from_test_execution("PROJECT-123")
1376            >>> print(tests)
1377            {"PROJECT-124": "10001", "PROJECT-125": "10002"}
1378        Note:
1379            - The method is limited to retrieving 99999 tests per test execution
1380            - Test execution must exist in Xray and be accessible with current authentication
1381        """
1382        try:
1383            test_execution_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1384            if not test_execution_id:
1385                logger.error(f"Failed to get test execution ID for {test_execution}")
1386                return None
1387            query = """
1388            query GetTestExecutionTests($testExecutionId: String!) {
1389                getTestExecution(issueId: $testExecutionId) {
1390                    tests(limit: 100) {
1391                        results {   
1392                            issueId
1393                            jira(fields: ["key"])
1394                        }
1395                    }
1396                }
1397            }
1398            """
1399            variables = {"testExecutionId": test_execution_id}
1400            data = self._make_graphql_request(query, variables)
1401            if not data:
1402                logger.error(f"Failed to get tests for execution {test_execution_id}")
1403                return None
1404            tests = {}
1405            for test in data['getTestExecution']['tests']['results']:
1406                tests[test['jira']['key']] = test['issueId']
1407            return tests
1408        except Exception as e:
1409            logger.error(f"Failed to get tests for execution {test_execution_id}")
1410            logger.traceback(e)
1411            return None

Retrieves all tests associated with a given test execution from Xray. This method queries the Xray GraphQL API to fetch all tests that are part of the specified test execution. It first converts the JIRA test execution key to an internal Xray ID, then uses that ID to fetch the associated tests. Args: test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123") Returns: Optional[Dict[str, str]]: A dictionary mapping test JIRA keys to their Xray issue IDs, or None if the operation fails. For example: { "PROJECT-124": "10001", "PROJECT-125": "10002" } Returns None in the following cases: - Test execution ID cannot be found - GraphQL request fails - Any other error occurs during processing Example:

client = XrayGraphQL() tests = client.get_tests_from_test_execution("PROJECT-123") print(tests) {"PROJECT-124": "10001", "PROJECT-125": "10002"} Note: - The method is limited to retrieving 99999 tests per test execution - Test execution must exist in Xray and be accessible with current authentication

def get_test_plan_data( self, test_plan: str) -> Optional[Dict[str, Union[List[int], List[float]]]]:
1413    def get_test_plan_data(self, test_plan: str) -> Optional[Dict[str, Union[List[int], List[float]]]]:
1414        """
1415        Retrieve and parse tabular data from a test plan's description field in Xray.
1416        This method fetches a test plan's description from Xray and parses any tables found within it.
1417        The tables in the description are expected to be in a specific format that can be parsed by
1418        the _parse_table method. The parsed data is returned as a dictionary containing numeric values
1419        and lists extracted from the table.
1420        Args:
1421            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1422        Returns:
1423            Optional[Dict[str, Union[List[int], List[float]]]]: A dictionary containing the parsed table data
1424                where keys are derived from the first column of the table and values are lists of numeric
1425                values. Returns None if:
1426                - The test plan ID cannot be found
1427                - The GraphQL request fails
1428                - The description cannot be parsed
1429                - Any other error occurs during processing
1430        Example:
1431            >>> client = XrayGraphQL()
1432            >>> data = client.get_test_plan_data("TEST-123")
1433            >>> print(data)
1434            {
1435                'temperature': [20, 25, 30],
1436                'pressure': [1.0, 1.5, 2.0],
1437                'measurements': [[1, 2, 3], [4, 5, 6]]
1438            }
1439        Note:
1440            - The test plan must exist in Xray and be accessible with current authentication
1441            - The description must contain properly formatted tables for parsing
1442            - Table values are converted to numeric types (int or float) where possible
1443            - Lists in table cells should be formatted as [value1, value2, ...]
1444            - Failed operations are logged as errors with relevant details
1445        """
1446        try:
1447            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1448            if not test_plan_id:
1449                logger.error(f"Failed to get test plan ID for {test_plan}")
1450                return None
1451            query = """
1452            query GetTestPlanTests($testPlanId: String!) {
1453                getTestPlan(issueId: $testPlanId) {
1454                    issueId
1455                    jira(fields: ["key","description"])
1456                }
1457            }
1458            """
1459            variables = {"testPlanId": test_plan_id}
1460            data = self._make_graphql_request(query, variables)
1461            if not data:
1462                logger.error(f"Failed to get tests for plan {test_plan_id}")
1463                return None
1464            description = data['getTestPlan']['jira']['description']
1465            test_plan_data = self._parse_table(description)
1466            return test_plan_data            
1467        except Exception as e:
1468            logger.error(f"Failed to get tests for plan {test_plan_id}")
1469            logger.traceback(e)
1470            return None

Retrieve and parse tabular data from a test plan's description field in Xray. This method fetches a test plan's description from Xray and parses any tables found within it. The tables in the description are expected to be in a specific format that can be parsed by the _parse_table method. The parsed data is returned as a dictionary containing numeric values and lists extracted from the table. Args: test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123") Returns: Optional[Dict[str, Union[List[int], List[float]]]]: A dictionary containing the parsed table data where keys are derived from the first column of the table and values are lists of numeric values. Returns None if: - The test plan ID cannot be found - The GraphQL request fails - The description cannot be parsed - Any other error occurs during processing Example:

client = XrayGraphQL() data = client.get_test_plan_data("TEST-123") print(data) { 'temperature': [20, 25, 30], 'pressure': [1.0, 1.5, 2.0], 'measurements': [[1, 2, 3], [4, 5, 6]] } Note: - The test plan must exist in Xray and be accessible with current authentication - The description must contain properly formatted tables for parsing - Table values are converted to numeric types (int or float) where possible - Lists in table cells should be formatted as [value1, value2, ...] - Failed operations are logged as errors with relevant details

def filter_test_set_by_test_case(self, test_key: str) -> Optional[Dict[str, str]]:
1472    def filter_test_set_by_test_case(self, test_key: str) -> Optional[Dict[str, str]]:
1473        """
1474        Retrieves all test sets that contain a specific test case from Xray.
1475        This method queries the Xray GraphQL API to find all test sets that include the specified
1476        test case. It first converts the JIRA test case key to an internal Xray ID, then uses that
1477        ID to fetch all associated test sets.
1478        Args:
1479            test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1480        Returns:
1481            Optional[Dict[str, str]]: A dictionary mapping test set JIRA keys to their summaries,
1482                or None if the operation fails. For example:
1483                {
1484                    "PROJECT-124": "Test Set for Feature A",
1485                    "PROJECT-125": "Regression Test Set"
1486                }
1487                Returns None in the following cases:
1488                - Test case ID cannot be found
1489                - GraphQL request fails
1490                - Any other error occurs during processing
1491        Example:
1492            >>> client = XrayGraphQL()
1493            >>> test_sets = client.filter_test_set_by_test_case("PROJECT-123")
1494            >>> print(test_sets)
1495            {"PROJECT-124": "Test Set for Feature A", "PROJECT-125": "Regression Test Set"}
1496        Note:
1497            - The method is limited to retrieving 99999 test sets per test case
1498            - Test case must exist in Xray and be accessible with current authentication
1499        """
1500        try:
1501            test_id = self.get_issue_id_from_jira_id(test_key, "test")
1502            if not test_id:
1503                logger.error(f"Failed to get test ID for  Test Case ({test_key})")
1504                return None
1505            query = """
1506            query GetTestDetails($testId: String!) {
1507                getTest(issueId: $testId) {
1508                    testSets(limit: 100) {
1509                        results {   
1510                            issueId
1511                            jira(fields: ["key","summary"])
1512                        }
1513                    }
1514                }
1515            }   
1516            """
1517            variables = {
1518                "testId": test_id
1519            }
1520            data = self._make_graphql_request(query, variables)
1521            if not data:
1522                logger.error(f"Failed to get tests for plan {test_id}")
1523                return None
1524            retDict = {}
1525            for test in data['getTest']['testSets']['results']:
1526                retDict[test['jira']['key']] = test['jira']['summary']
1527            return retDict
1528        except Exception as e:
1529            logger.error(f"Error in getting test set by test id: {e}")
1530            logger.traceback(e)
1531            return None 

Retrieves all test sets that contain a specific test case from Xray. This method queries the Xray GraphQL API to find all test sets that include the specified test case. It first converts the JIRA test case key to an internal Xray ID, then uses that ID to fetch all associated test sets. Args: test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123") Returns: Optional[Dict[str, str]]: A dictionary mapping test set JIRA keys to their summaries, or None if the operation fails. For example: { "PROJECT-124": "Test Set for Feature A", "PROJECT-125": "Regression Test Set" } Returns None in the following cases: - Test case ID cannot be found - GraphQL request fails - Any other error occurs during processing Example:

client = XrayGraphQL() test_sets = client.filter_test_set_by_test_case("PROJECT-123") print(test_sets) {"PROJECT-124": "Test Set for Feature A", "PROJECT-125": "Regression Test Set"} Note: - The method is limited to retrieving 99999 test sets per test case - Test case must exist in Xray and be accessible with current authentication

def filter_tags_by_test_case(self, test_key: str) -> Optional[List[str]]:
1533    def filter_tags_by_test_case(self, test_key: str) -> Optional[List[str]]:
1534        """
1535        Extract and filter tags from test sets associated with a specific test case in Xray.
1536        This method queries the Xray GraphQL API to find all test sets associated with the given
1537        test case and extracts tags from their summaries. Tags are identified from test set summaries
1538        that start with either 'tag' or 'benchtype' prefixes.
1539        Args:
1540            test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1541        Returns:
1542            Optional[List[str]]: A list of unique tags extracted from test set summaries,
1543                or None if no tags are found or an error occurs. Tags are:
1544                - Extracted from summaries starting with 'tag:' or 'benchtype:'
1545                - Split on commas, semicolons, double pipes, or whitespace
1546                - Converted to lowercase and stripped of whitespace
1547        Example:
1548            >>> client = XrayGraphQL()
1549            >>> tags = client.filter_tags_by_test_case("TEST-123")
1550            >>> print(tags)
1551            ['regression', 'smoke', 'performance']
1552        Note:
1553            - Test sets must have summaries in the format "tag: tag1, tag2" or "benchtype: type1, type2"
1554            - Tags are extracted only from summaries with the correct prefix
1555            - All tags are converted to lowercase for consistency
1556            - Duplicate tags are automatically removed via set conversion
1557            - Returns None if no valid tags are found or if an error occurs
1558        """
1559        try:
1560            test_id = self.get_issue_id_from_jira_id(test_key, "test")
1561            if not test_id:
1562                logger.error(f"Failed to get test ID for  Test Case ({test_key})")
1563                return None
1564            query = """
1565            query GetTestDetails($testId: String!) {
1566                getTest(issueId: $testId) {
1567                    testSets(limit: 100) {
1568                        results {   
1569                            issueId
1570                            jira(fields: ["key","summary"])
1571                        }
1572                    }
1573                }
1574            }   
1575            """
1576            variables = {
1577                "testId": test_id
1578            }
1579            data = self._make_graphql_request(query, variables)
1580            if not data:
1581                logger.error(f"Failed to get tests for plan {test_id}")
1582                return None
1583            tags = set()
1584            for test in data['getTest']['testSets']['results']:
1585                summary = str(test['jira']['summary']).strip().lower()
1586                if summary.startswith(('tag', 'benchtype')):
1587                    summary = summary.split(':', 1)[-1].strip()  # Split only once and take last part
1588                    tags.update(tag for tag in re.split(r'[,|;|\|\|]\s*|\s+', summary) if tag)
1589            if tags:
1590                return list(tags)
1591            else:
1592                return None
1593        except Exception as e:
1594            logger.error(f"Error in getting test set by test id: {e}")
1595            logger.traceback(e)
1596            return None 

Extract and filter tags from test sets associated with a specific test case in Xray. This method queries the Xray GraphQL API to find all test sets associated with the given test case and extracts tags from their summaries. Tags are identified from test set summaries that start with either 'tag' or 'benchtype' prefixes. Args: test_key (str): The JIRA issue key of the test case (e.g., "PROJECT-123") Returns: Optional[List[str]]: A list of unique tags extracted from test set summaries, or None if no tags are found or an error occurs. Tags are: - Extracted from summaries starting with 'tag:' or 'benchtype:' - Split on commas, semicolons, double pipes, or whitespace - Converted to lowercase and stripped of whitespace Example:

client = XrayGraphQL() tags = client.filter_tags_by_test_case("TEST-123") print(tags) ['regression', 'smoke', 'performance'] Note: - Test sets must have summaries in the format "tag: tag1, tag2" or "benchtype: type1, type2" - Tags are extracted only from summaries with the correct prefix - All tags are converted to lowercase for consistency - Duplicate tags are automatically removed via set conversion - Returns None if no valid tags are found or if an error occurs

def get_test_runstatus(self, test_case: str, test_execution: str) -> Optional[Tuple[str, str]]:
1598    def get_test_runstatus(self, test_case: str, test_execution: str) -> Optional[Tuple[str, str]]:
1599        """
1600        Retrieve the status of a test run for a specific test case within a test execution.
1601        This method queries the Xray GraphQL API to get the current status of a test run,
1602        which represents the execution status of a specific test case within a test execution.
1603        It first converts both the test case and test execution JIRA keys to their internal
1604        Xray IDs, then uses these to fetch the test run status.
1605        Args:
1606            test_case (str): The JIRA issue key of the test case (e.g., "PROJECT-123")
1607            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-456")
1608        Returns:
1609            Tuple[Optional[str], Optional[str]]: A tuple containing:
1610                - test_run_id: The unique identifier of the test run (or None if not found)
1611                - test_run_status: The current status of the test run (or None if not found)
1612                Returns (None, None) if any error occurs during the process.
1613        Example:
1614            >>> client = XrayGraphQL()
1615            >>> run_id, status = client.get_test_runstatus("TEST-123", "TEST-456")
1616            >>> print(f"Test Run ID: {run_id}, Status: {status}")
1617            Test Run ID: 10001, Status: PASS
1618        Note:
1619            - Both the test case and test execution must exist in Xray and be accessible
1620            - The test case must be associated with the test execution
1621            - The method performs two ID lookups before querying the test run status
1622            - Failed operations are logged as errors with relevant details
1623        """
1624        try:
1625            test_case_id = self.get_issue_id_from_jira_id(test_case, "test")
1626            if not test_case_id:
1627                logger.error(f"Failed to get test ID for Test Case ({test_case})")
1628                return None
1629            test_exec_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1630            if not test_exec_id:
1631                logger.error(f"Failed to get test execution ID for Test Execution ({test_execution})")
1632                return None
1633            query = """
1634            query GetTestRunStatus($testId: String!, $testExecutionId: String!) {
1635                getTestRun( testIssueId: $testId, testExecIssueId: $testExecutionId) {
1636                    id
1637                    status {
1638                        name
1639                    }
1640                }
1641            }
1642            """
1643            variables = {
1644                "testId": test_case_id,
1645                "testExecutionId": test_exec_id,
1646            }
1647            # Add debug loggerging
1648            logger.debug(f"Getting test run status for test {test_case_id} in execution {test_exec_id}")
1649            data = self._make_graphql_request(query, variables)
1650            if not data:
1651                logger.error(f"Failed to get test run status for test {test_case_id}")
1652                return None
1653            # jprint(data)
1654            test_run_id = data['getTestRun']['id']
1655            test_run_status = data['getTestRun']['status']['name']
1656            return (test_run_id, test_run_status)
1657        except Exception as e:
1658            logger.error(f"Error getting test run status: {str(e)}")
1659            logger.traceback(e)
1660            return (None, None)

Retrieve the status of a test run for a specific test case within a test execution. This method queries the Xray GraphQL API to get the current status of a test run, which represents the execution status of a specific test case within a test execution. It first converts both the test case and test execution JIRA keys to their internal Xray IDs, then uses these to fetch the test run status. Args: test_case (str): The JIRA issue key of the test case (e.g., "PROJECT-123") test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-456") Returns: Tuple[Optional[str], Optional[str]]: A tuple containing: - test_run_id: The unique identifier of the test run (or None if not found) - test_run_status: The current status of the test run (or None if not found) Returns (None, None) if any error occurs during the process. Example:

client = XrayGraphQL() run_id, status = client.get_test_runstatus("TEST-123", "TEST-456") print(f"Test Run ID: {run_id}, Status: {status}") Test Run ID: 10001, Status: PASS Note: - Both the test case and test execution must exist in Xray and be accessible - The test case must be associated with the test execution - The method performs two ID lookups before querying the test run status - Failed operations are logged as errors with relevant details

def get_test_run_by_id( self, test_case_id: str, test_execution_id: str) -> Optional[Tuple[str, str]]:
1662    def get_test_run_by_id(self, test_case_id: str, test_execution_id: str) -> Optional[Tuple[str, str]]:
1663        """
1664        Retrieves the test run ID and status for a specific test case within a test execution using GraphQL.
1665        Args:
1666            test_case_id (str): The ID of the test case to query
1667            test_execution_id (str): The ID of the test execution containing the test run
1668        Returns:
1669            tuple[Optional[str], Optional[str]]: A tuple containing:
1670                - test_run_id: The ID of the test run if found, None if not found or on error
1671                - test_run_status: The status name of the test run if found, None if not found or on error
1672        Note:
1673            The function makes a GraphQL request to fetch the test run information. If the request fails
1674            or encounters any errors, it will log the error and return (None, None).
1675        """
1676        try:
1677            query = """
1678            query GetTestRunStatus($testId: String!, $testExecutionId: String!) {
1679                getTestRun( testIssueId: $testId, testExecIssueId: $testExecutionId) {
1680                    id
1681                    status {
1682                        name
1683                    }
1684                }
1685            }
1686            """
1687            variables = {
1688                "testId": test_case_id,
1689                "testExecutionId": test_execution_id,
1690            }
1691            # Add debug loggerging
1692            logger.debug(f"Getting test run status for test {test_case_id} in execution {test_execution_id}")
1693            data = self._make_graphql_request(query, variables)
1694            if not data:
1695                logger.error(f"Failed to get test run status for test {test_case_id}")
1696                return None
1697            test_run_id = data['getTestRun']['id']
1698            test_run_status = data['getTestRun']['status']['name']
1699            return (test_run_id, test_run_status)
1700        except Exception as e:
1701            logger.error(f"Error getting test run status: {str(e)}")
1702            logger.traceback(e)
1703            return (None, None)

Retrieves the test run ID and status for a specific test case within a test execution using GraphQL. Args: test_case_id (str): The ID of the test case to query test_execution_id (str): The ID of the test execution containing the test run Returns: tuple[Optional[str], Optional[str]]: A tuple containing: - test_run_id: The ID of the test run if found, None if not found or on error - test_run_status: The status name of the test run if found, None if not found or on error Note: The function makes a GraphQL request to fetch the test run information. If the request fails or encounters any errors, it will log the error and return (None, None).

def get_test_execution(self, test_execution: str) -> Optional[Dict]:
1705    def get_test_execution(self, test_execution: str) -> Optional[Dict]:
1706        """
1707        Retrieve detailed information about a test execution from Xray.
1708        This method queries the Xray GraphQL API to fetch information about a specific test execution,
1709        including its ID and associated tests. It first converts the JIRA test execution key to an
1710        internal Xray ID, then uses that ID to fetch the execution details.
1711        Args:
1712            test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123")
1713        Returns:
1714            Optional[Dict]: A dictionary containing test execution details if successful, None if failed.
1715                The dictionary has the following structure:
1716                {
1717                    'id': str,          # The internal Xray ID of the test execution
1718                    'tests': {          # Dictionary mapping test keys to their IDs
1719                        'TEST-124': '10001',
1720                        'TEST-125': '10002',
1721                        ...
1722                    }
1723                }
1724                Returns None in the following cases:
1725                - Test execution ID cannot be found
1726                - GraphQL request fails
1727                - No test execution found with the given ID
1728                - No tests found in the test execution
1729                - Any other error occurs during processing
1730        Example:
1731            >>> client = XrayGraphQL()
1732            >>> execution = client.get_test_execution("TEST-123")
1733            >>> print(execution)
1734            {
1735                'id': '10000',
1736                'tests': {
1737                    'TEST-124': '10001',
1738                    'TEST-125': '10002'
1739                }
1740            }
1741        Note:
1742            - The method is limited to retrieving 99999 tests per test execution
1743            - Test execution must exist in Xray and be accessible with current authentication
1744            - Failed operations are logged with appropriate error or warning messages
1745        """
1746        try:
1747            test_execution_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1748            if not test_execution_id:
1749                logger.error(f"Failed to get test execution ID for {test_execution}")
1750                return None
1751            query = """
1752            query GetTestExecution($testExecutionId: String!) {
1753                getTestExecution(issueId: $testExecutionId) {
1754                    issueId
1755                    projectId
1756                    jira(fields: ["key", "summary", "description", "status"])
1757                    tests(limit: 100) {
1758                        total
1759                        start
1760                        limit
1761                        results {
1762                            issueId
1763                            jira(fields: ["key"])
1764                        }
1765                    }
1766                }
1767            }
1768            """
1769            variables = {
1770                "testExecutionId": test_execution_id
1771            }
1772            # Add debug loggerging
1773            logger.debug(f"Getting test execution details for {test_execution_id}")
1774            data = self._make_graphql_request(query, variables)
1775            # jprint(data)
1776            if not data:
1777                logger.error(f"Failed to get test execution details for {test_execution_id}")
1778                return None
1779            test_execution = data.get('getTestExecution',{})
1780            if not test_execution:
1781                logger.warning(f"No test execution found with ID {test_execution_id}")
1782                return None
1783            tests = test_execution.get('tests',{})
1784            if not tests:
1785                logger.warning(f"No tests found for test execution {test_execution_id}")
1786                return None
1787            tests_details = dict()
1788            for test in tests['results']:
1789                tests_details[test['jira']['key']] = test['issueId']
1790            formatted_response = {
1791                'id': test_execution['issueId'],
1792                'tests': tests_details
1793            }
1794            return formatted_response
1795        except Exception as e:
1796            logger.error(f"Error getting test execution details: {str(e)}")
1797            logger.traceback(e)
1798            return None

Retrieve detailed information about a test execution from Xray. This method queries the Xray GraphQL API to fetch information about a specific test execution, including its ID and associated tests. It first converts the JIRA test execution key to an internal Xray ID, then uses that ID to fetch the execution details. Args: test_execution (str): The JIRA issue key of the test execution (e.g., "PROJECT-123") Returns: Optional[Dict]: A dictionary containing test execution details if successful, None if failed. The dictionary has the following structure: { 'id': str, # The internal Xray ID of the test execution 'tests': { # Dictionary mapping test keys to their IDs 'TEST-124': '10001', 'TEST-125': '10002', ... } } Returns None in the following cases: - Test execution ID cannot be found - GraphQL request fails - No test execution found with the given ID - No tests found in the test execution - Any other error occurs during processing Example:

client = XrayGraphQL() execution = client.get_test_execution("TEST-123") print(execution) { 'id': '10000', 'tests': { 'TEST-124': '10001', 'TEST-125': '10002' } } Note: - The method is limited to retrieving 99999 tests per test execution - Test execution must exist in Xray and be accessible with current authentication - Failed operations are logged with appropriate error or warning messages

def add_test_execution_to_test_plan(self, test_plan: str, test_execution: str) -> Optional[Dict]:
1800    def add_test_execution_to_test_plan(self, test_plan: str, test_execution: str) -> Optional[Dict]:
1801        """
1802        Add a test execution to an existing test plan in Xray.
1803        This method associates a test execution with a test plan using the Xray GraphQL API.
1804        It first converts both the test plan and test execution JIRA keys to their internal
1805        Xray IDs, then creates the association between them.
1806        Args:
1807            test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123")
1808            test_execution (str): The JIRA issue key of the test execution to add (e.g., "PROJECT-456")
1809        Returns:
1810            Optional[Dict]: A dictionary containing the response data if successful, None if failed.
1811                The dictionary has the following structure:
1812                {
1813                    'addTestExecutionsToTestPlan': {
1814                        'addedTestExecutions': [str],  # List of added test execution IDs
1815                        'warning': str                 # Any warnings from the operation
1816                    }
1817                }
1818                Returns None in the following cases:
1819                - Test plan ID cannot be found
1820                - Test execution ID cannot be found
1821                - GraphQL request fails
1822                - Any other error occurs during processing
1823        Example:
1824            >>> client = XrayGraphQL()
1825            >>> result = client.add_test_execution_to_test_plan("TEST-123", "TEST-456")
1826            >>> print(result)
1827            {
1828                'addTestExecutionsToTestPlan': {
1829                    'addedTestExecutions': ['10001'],
1830                    'warning': None
1831                }
1832            }
1833        Note:
1834            - Both the test plan and test execution must exist in Xray and be accessible
1835            - The method performs two ID lookups before creating the association
1836            - Failed operations are logged as errors with relevant details
1837        """
1838        try:
1839            test_plan_id = self.get_issue_id_from_jira_id(test_plan, "plan")
1840            if not test_plan_id:
1841                logger.error(f"Test plan ID is required")
1842                return None
1843            test_exec_id = self.get_issue_id_from_jira_id(test_execution, "exec")
1844            if not test_exec_id:
1845                logger.error(f"Test execution ID is required")
1846                return None
1847            query = """
1848            mutation AddTestExecutionToTestPlan($testPlanId: String!, $testExecutionIds: [String!]!) {
1849                addTestExecutionsToTestPlan(issueId: $testPlanId, testExecIssueIds: $testExecutionIds) {
1850                    addedTestExecutions 
1851                    warning
1852                }
1853            }
1854            """
1855            variables = {
1856                "testPlanId": test_plan_id,
1857                "testExecutionIds": [test_exec_id]
1858            }
1859            data = self._make_graphql_request(query, variables)
1860            return data
1861        except Exception as e:
1862            logger.error(f"Error adding test execution to test plan: {str(e)}")
1863            logger.traceback(e)
1864            return None

Add a test execution to an existing test plan in Xray. This method associates a test execution with a test plan using the Xray GraphQL API. It first converts both the test plan and test execution JIRA keys to their internal Xray IDs, then creates the association between them. Args: test_plan (str): The JIRA issue key of the test plan (e.g., "PROJECT-123") test_execution (str): The JIRA issue key of the test execution to add (e.g., "PROJECT-456") Returns: Optional[Dict]: A dictionary containing the response data if successful, None if failed. The dictionary has the following structure: { 'addTestExecutionsToTestPlan': { 'addedTestExecutions': [str], # List of added test execution IDs 'warning': str # Any warnings from the operation } } Returns None in the following cases: - Test plan ID cannot be found - Test execution ID cannot be found - GraphQL request fails - Any other error occurs during processing Example:

client = XrayGraphQL() result = client.add_test_execution_to_test_plan("TEST-123", "TEST-456") print(result) { 'addTestExecutionsToTestPlan': { 'addedTestExecutions': ['10001'], 'warning': None } } Note: - Both the test plan and test execution must exist in Xray and be accessible - The method performs two ID lookups before creating the association - Failed operations are logged as errors with relevant details

def create_test_execution( self, test_issue_keys: List[str], project_key: Optional[str] = None, summary: Optional[str] = None, description: Optional[str] = None) -> Optional[Dict]:
1866    def create_test_execution(self, 
1867                            test_issue_keys: List[str], 
1868                            project_key: Optional[str] = None, 
1869                            summary: Optional[str] = None, 
1870                            description: Optional[str] = None) -> Optional[Dict]:
1871        """
1872        Create a new test execution in Xray with specified test cases.
1873        This method creates a test execution ticket in JIRA/Xray that includes the specified test cases.
1874        It handles validation of test issue keys, automatically derives project information if not provided,
1875        and creates appropriate default values for summary and description if not specified.
1876        Args:
1877            test_issue_keys (List[str]): List of JIRA issue keys for test cases to include in the execution
1878                (e.g., ["TEST-123", "TEST-124"])
1879            project_key (Optional[str]): The JIRA project key where the test execution should be created.
1880                If not provided, it will be derived from the first test issue key.
1881            summary (Optional[str]): The summary/title for the test execution ticket.
1882                If not provided, a default summary will be generated using the test issue keys.
1883            description (Optional[str]): The description for the test execution ticket.
1884                If not provided, a default description will be generated using the test issue keys.
1885        Returns:
1886            Optional[Dict]: A dictionary containing the created test execution details if successful,
1887                None if the creation fails. The dictionary has the following structure:
1888                {
1889                    'issueId': str,      # The internal Xray ID of the created test execution
1890                    'jira': {
1891                        'key': str       # The JIRA issue key of the created test execution
1892                    }
1893                }
1894        Example:
1895            >>> client = XrayGraphQL()
1896            >>> test_execution = client.create_test_execution(
1897            ...     test_issue_keys=["TEST-123", "TEST-124"],
1898            ...     project_key="TEST",
1899            ...     summary="Sprint 1 Regression Tests"
1900            ... )
1901            >>> print(test_execution)
1902            {'issueId': '10001', 'jira': {'key': 'TEST-125'}}
1903        Note:
1904            - Invalid test issue keys are logged as warnings but don't prevent execution creation
1905            - At least one valid test issue key is required
1906            - The method validates each test issue key before creating the execution
1907            - Project key is automatically derived from the first test issue key if not provided
1908        """
1909        try:
1910            invalid_keys = []
1911            test_issue_ids = []
1912            for key in test_issue_keys:
1913                test_issue_id = self.get_issue_id_from_jira_id(key, "test")
1914                if test_issue_id:
1915                    test_issue_ids.append(test_issue_id)
1916                else:
1917                    invalid_keys.append(key)
1918            if len(test_issue_ids) == 0:
1919                logger.error(f"No valid test issue keys provided: {invalid_keys}")
1920                return None
1921            if len(invalid_keys) > 0:
1922                logger.warning(f"Invalid test issue keys: {invalid_keys}")
1923            if not project_key:
1924                project_key = test_issue_keys[0].split("-")[0]
1925            if not summary:
1926                summary = f"Test Execution for Test Plan {test_issue_keys}"
1927            if not description:
1928                description = f"Test Execution for Test Plan {test_issue_keys}"
1929            mutation = """
1930            mutation CreateTestExecutionForTestPlan(
1931                $testIssueId_list: [String!]!,
1932                $projectKey: String!,
1933                $summary: String!,
1934                $description: String
1935            ) {
1936                createTestExecution(
1937                    testIssueIds: $testIssueId_list,
1938                    jira: {
1939                        fields: {
1940                            project: { key: $projectKey },
1941                            summary: $summary,
1942                            description: $description,
1943                            issuetype: { name: "Test Execution" }
1944                        }
1945                    }
1946                ) {
1947                    testExecution {
1948                        issueId
1949                        jira(fields: ["key"])
1950                    }
1951                    warnings
1952                }
1953            }
1954            """
1955            variables = {
1956                "testIssueId_list": test_issue_ids,
1957                "projectKey": project_key,
1958                "summary": summary,
1959                "description": description
1960            }
1961            data = self._make_graphql_request(mutation, variables)
1962            if not data:
1963                return None
1964            execution_details = data['createTestExecution']['testExecution']
1965            # logger.info(f"Created test execution {execution_details['jira']['key']}")
1966            return execution_details
1967        except Exception as e:
1968            logger.error("Failed to create test execution : {e}")
1969            logger.traceback(e)
1970            return None

Create a new test execution in Xray with specified test cases. This method creates a test execution ticket in JIRA/Xray that includes the specified test cases. It handles validation of test issue keys, automatically derives project information if not provided, and creates appropriate default values for summary and description if not specified. Args: test_issue_keys (List[str]): List of JIRA issue keys for test cases to include in the execution (e.g., ["TEST-123", "TEST-124"]) project_key (Optional[str]): The JIRA project key where the test execution should be created. If not provided, it will be derived from the first test issue key. summary (Optional[str]): The summary/title for the test execution ticket. If not provided, a default summary will be generated using the test issue keys. description (Optional[str]): The description for the test execution ticket. If not provided, a default description will be generated using the test issue keys. Returns: Optional[Dict]: A dictionary containing the created test execution details if successful, None if the creation fails. The dictionary has the following structure: { 'issueId': str, # The internal Xray ID of the created test execution 'jira': { 'key': str # The JIRA issue key of the created test execution } } Example:

client = XrayGraphQL() test_execution = client.create_test_execution( ... test_issue_keys=["TEST-123", "TEST-124"], ... project_key="TEST", ... summary="Sprint 1 Regression Tests" ... ) print(test_execution) {'issueId': '10001', 'jira': {'key': 'TEST-125'}} Note: - Invalid test issue keys are logged as warnings but don't prevent execution creation - At least one valid test issue key is required - The method validates each test issue key before creating the execution - Project key is automatically derived from the first test issue key if not provided

def create_test_execution_from_test_plan(self, test_plan: str) -> Optional[Dict[str, Dict[str, str]]]:
1972    def create_test_execution_from_test_plan(self, test_plan: str) -> Optional[Dict[str, Dict[str, str]]]:
1973        """Creates a test execution from a given test plan and associates all tests from the plan with the execution.
1974        This method performs several operations in sequence:
1975        1. Retrieves all tests from the specified test plan
1976        2. Creates a new test execution with those tests
1977        3. Associates the new test execution with the original test plan
1978        4. Creates test runs for each test in the execution
1979        Parameters
1980        ----------
1981        test_plan : str
1982            The JIRA issue key of the test plan (e.g., "PROJECT-123")
1983        Returns
1984        -------
1985        Optional[Dict[str, Dict[str, str]]]
1986            A dictionary mapping test case keys to their execution details, or None if the operation fails.
1987            The dictionary structure is::
1988                {
1989                    "TEST-123": {                    # Test case JIRA key
1990                        "test_run_id": "12345",      # Unique ID for this test run
1991                        "test_execution_key": "TEST-456",  # JIRA key of the created test execution
1992                        "test_plan_key": "TEST-789"  # Original test plan JIRA key
1993                    },
1994                    "TEST-124": {
1995                        ...
1996                    }
1997                }
1998            Returns None in the following cases:
1999            * Test plan parameter is empty or invalid
2000            * No tests found in the test plan
2001            * Test execution creation fails
2002            * API request fails
2003        Examples
2004        --------
2005        >>> client = XrayGraphQL()
2006        >>> result = client.create_test_execution_from_test_plan("TEST-123")
2007        >>> print(result)
2008        {
2009            "TEST-124": {
2010                "test_run_id": "5f7c3",
2011                "test_execution_key": "TEST-456",
2012                "test_plan_key": "TEST-123"
2013            },
2014            "TEST-125": {
2015                "test_run_id": "5f7c4",
2016                "test_execution_key": "TEST-456",
2017                "test_plan_key": "TEST-123"
2018            }
2019        }
2020        Notes
2021        -----
2022        - The test plan must exist and be accessible in Xray
2023        - All tests in the test plan must be valid and accessible
2024        - The method automatically generates a summary and description for the test execution
2025        - The created test execution is automatically linked back to the original test plan
2026        """
2027        try:
2028            if not test_plan:
2029                logger.error("Test plan is required [ jira key]")
2030                return None
2031            project_key = test_plan.split("-")[0]
2032            summary = f"Test Execution for Test Plan {test_plan}"
2033            retDict = dict()
2034            #Get tests from test plan
2035            tests = self.get_tests_from_test_plan(test_plan)
2036            retDict["tests"] = tests
2037            testIssueId_list = list(tests.values())
2038            # logger.info(f"Tests: {tests}")
2039            if not testIssueId_list:
2040                logger.error(f"No tests found for {test_plan}")
2041                return None
2042            description = f"Test Execution for {len(tests)} Test cases"
2043            # GraphQL mutation to create test execution
2044            query = """
2045                mutation CreateTestExecutionForTestPlan(
2046                    $testIssueId_list: [String!]!,
2047                    $projectKey: String!,
2048                    $summary: String!,
2049                    $description: String
2050                ) {
2051                    createTestExecution(
2052                        testIssueIds: $testIssueId_list,
2053                        jira: {
2054                            fields: {
2055                                project: { key: $projectKey },
2056                                summary: $summary,
2057                                description: $description,
2058                                issuetype: { name: "Test Execution" }
2059                            }
2060                        }
2061                    ) {
2062                        testExecution {
2063                            issueId
2064                            jira(fields: ["key"])
2065                            testRuns(limit: 100) {
2066                                results {
2067                                    id
2068                                    test {
2069                                        issueId
2070                                        jira(fields: ["key"])
2071                                    }
2072                                }
2073                            }
2074                        }
2075                        warnings
2076                    }
2077                }
2078            """
2079            variables = {
2080                "testIssueId_list": testIssueId_list,
2081                "projectKey": project_key,
2082                "summary": summary,
2083                "description": description or f"Test execution for total of {len(testIssueId_list)} test cases"
2084            }
2085            data = self._make_graphql_request(query, variables)
2086            if not data:
2087                return None
2088            test_exec_key = data['createTestExecution']['testExecution']['jira']['key']
2089            test_exec_id = data['createTestExecution']['testExecution']['issueId']
2090            #Add Test execution to test plan
2091            test_exec_dict= self.add_test_execution_to_test_plan(test_plan, test_exec_key)
2092            #Get test runs for test execution
2093            test_runs = data['createTestExecution']['testExecution']['testRuns']['results']
2094            test_run_dict = dict()
2095            for test_run in test_runs:
2096                test_run_dict[test_run['test']['jira']['key']] = dict()
2097                test_run_dict[test_run['test']['jira']['key']]['test_run_id'] = test_run['id']
2098                # test_run_dict[test_run['test']['jira']['key']]['test_issue_id'] = test_run['test']['issueId']
2099                test_run_dict[test_run['test']['jira']['key']]['test_execution_key'] = test_exec_key
2100                # test_run_dict[test_run['test']['jira']['key']]['test_execution_id'] = test_exec_id
2101                test_run_dict[test_run['test']['jira']['key']]['test_plan_key'] = test_plan
2102            return test_run_dict
2103        except requests.exceptions.RequestException as e:
2104            logger.error(f"Error creating test execution: {e}")
2105            logger.traceback(e)
2106        return None

Creates a test execution from a given test plan and associates all tests from the plan with the execution. This method performs several operations in sequence:

  1. Retrieves all tests from the specified test plan
  2. Creates a new test execution with those tests
  3. Associates the new test execution with the original test plan
  4. Creates test runs for each test in the execution

Parameters

test_plan : str The JIRA issue key of the test plan (e.g., "PROJECT-123")

Returns

Optional[Dict[str, Dict[str, str]]] A dictionary mapping test case keys to their execution details, or None if the operation fails. The dictionary structure is:: { "TEST-123": { # Test case JIRA key "test_run_id": "12345", # Unique ID for this test run "test_execution_key": "TEST-456", # JIRA key of the created test execution "test_plan_key": "TEST-789" # Original test plan JIRA key }, "TEST-124": { ... } } Returns None in the following cases: * Test plan parameter is empty or invalid * No tests found in the test plan * Test execution creation fails * API request fails

Examples

>>> client = XrayGraphQL()
>>> result = client.create_test_execution_from_test_plan("TEST-123")
>>> print(result)
{
    "TEST-124": {
        "test_run_id": "5f7c3",
        "test_execution_key": "TEST-456",
        "test_plan_key": "TEST-123"
    },
    "TEST-125": {
        "test_run_id": "5f7c4",
        "test_execution_key": "TEST-456",
        "test_plan_key": "TEST-123"
    }
}
<h2 id="notes">Notes</h2>
  • The test plan must exist and be accessible in Xray
  • All tests in the test plan must be valid and accessible
  • The method automatically generates a summary and description for the test execution
  • The created test execution is automatically linked back to the original test plan
def update_test_run_status(self, test_run_id: str, test_run_status: str) -> bool:
2108    def update_test_run_status(self, test_run_id: str, test_run_status: str) -> bool:
2109        """
2110        Update the status of a specific test run in Xray using the GraphQL API.
2111        This method allows updating the execution status of a test run identified by its ID.
2112        The status can be changed to reflect the current state of the test execution
2113        (e.g., "PASS", "FAIL", "TODO", etc.).
2114        Args:
2115            test_run_id (str): The unique identifier of the test run to update.
2116                This is the internal Xray ID for the test run, not the Jira issue key.
2117            test_run_status (str): The new status to set for the test run.
2118                Common values include: "PASS", "FAIL", "TODO", "EXECUTING", etc.
2119        Returns:
2120            bool: True if the status update was successful, False otherwise.
2121                Returns None if an error occurs during the API request.
2122        Example:
2123            >>> client = XrayGraphQL()
2124            >>> test_run_id = client.get_test_run_status("TEST-CASE-KEY", "TEST-EXEC-KEY")
2125            >>> success = client.update_test_run_status(test_run_id, "PASS")
2126            >>> print(success)
2127            True
2128        Note:
2129            - The test run ID must be valid and accessible with current authentication
2130            - The status value should be one of the valid status values configured in your Xray instance
2131            - Failed updates are logged as errors with details about the failure
2132        Raises:
2133            Exception: If there is an error making the GraphQL request or processing the response.
2134                The exception is caught and logged, and the method returns None.
2135        """
2136        try:
2137            query = """
2138            mutation UpdateTestRunStatus($testRunId: String!, $status: String!) {
2139                updateTestRunStatus(
2140                    id: $testRunId, 
2141                    status: $status 
2142                ) 
2143            }                       
2144            """
2145            variables = {
2146                "testRunId": test_run_id,
2147                "status": test_run_status
2148            }
2149            data = self._make_graphql_request(query, variables)
2150            if not data:
2151                logger.error(f"Failed to get test run status for test {data}")
2152                return None
2153            # logger.info(f"Test run status updated: {data}")
2154            return data['updateTestRunStatus']
2155        except Exception as e:
2156            logger.error(f"Error updating test run status: {str(e)}")
2157            logger.traceback(e)
2158            return None

Update the status of a specific test run in Xray using the GraphQL API. This method allows updating the execution status of a test run identified by its ID. The status can be changed to reflect the current state of the test execution (e.g., "PASS", "FAIL", "TODO", etc.). Args: test_run_id (str): The unique identifier of the test run to update. This is the internal Xray ID for the test run, not the Jira issue key. test_run_status (str): The new status to set for the test run. Common values include: "PASS", "FAIL", "TODO", "EXECUTING", etc. Returns: bool: True if the status update was successful, False otherwise. Returns None if an error occurs during the API request. Example:

client = XrayGraphQL() test_run_id = client.get_test_run_status("TEST-CASE-KEY", "TEST-EXEC-KEY") success = client.update_test_run_status(test_run_id, "PASS") print(success) True Note: - The test run ID must be valid and accessible with current authentication - The status value should be one of the valid status values configured in your Xray instance - Failed updates are logged as errors with details about the failure Raises: Exception: If there is an error making the GraphQL request or processing the response. The exception is caught and logged, and the method returns None.

def update_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2160    def update_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2161        """
2162        Update the comment of a specific test run in Xray using the GraphQL API.
2163        This method allows adding or updating the comment associated with a test run
2164        identified by its ID. The comment can provide additional context, test results,
2165        or any other relevant information about the test execution.
2166        Args:
2167            test_run_id (str): The unique identifier of the test run to update.
2168                This is the internal Xray ID for the test run, not the Jira issue key.
2169            test_run_comment (str): The new comment text to set for the test run.
2170                This will replace any existing comment on the test run.
2171        Returns:
2172            bool: True if the comment update was successful, False otherwise.
2173                Returns None if an error occurs during the API request.
2174        Example:
2175            >>> client = XrayGraphQL()
2176            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2177            >>> success = client.update_test_run_comment(
2178            ...     test_run_id,
2179            ...     "Test passed with performance within expected range"
2180            ... )
2181            >>> print(success)
2182            True
2183        Note:
2184            - The test run ID must be valid and accessible with current authentication
2185            - The comment can include any text content, including newlines and special characters
2186            - Failed updates are logged as errors with details about the failure
2187            - This method will overwrite any existing comment on the test run
2188        Raises:
2189            Exception: If there is an error making the GraphQL request or processing the response.
2190                The exception is caught and logged, and the method returns None.
2191        """
2192        try:
2193            query = """
2194            mutation UpdateTestRunStatus($testRunId: String!, $comment: String!) {
2195                updateTestRunComment(
2196                    id: $testRunId, 
2197                    comment: $comment 
2198                ) 
2199            }                       
2200            """
2201            variables = {
2202                "testRunId": test_run_id,
2203                "comment": test_run_comment
2204            }
2205            data = self._make_graphql_request(query, variables)
2206            if not data:
2207                logger.error(f"Failed to get test run comment for test {data}")
2208                return None
2209            # jprint(data)
2210            return data['updateTestRunComment']
2211        except Exception as e:
2212            logger.error(f"Error updating test run comment: {str(e)}")
2213            logger.traceback(e)
2214            return None

Update the comment of a specific test run in Xray using the GraphQL API. This method allows adding or updating the comment associated with a test run identified by its ID. The comment can provide additional context, test results, or any other relevant information about the test execution. Args: test_run_id (str): The unique identifier of the test run to update. This is the internal Xray ID for the test run, not the Jira issue key. test_run_comment (str): The new comment text to set for the test run. This will replace any existing comment on the test run. Returns: bool: True if the comment update was successful, False otherwise. Returns None if an error occurs during the API request. Example:

client = XrayGraphQL() test_run_id = "67fcfd4b9e6d63d4c1d57b32" success = client.update_test_run_comment( ... test_run_id, ... "Test passed with performance within expected range" ... ) print(success) True Note: - The test run ID must be valid and accessible with current authentication - The comment can include any text content, including newlines and special characters - Failed updates are logged as errors with details about the failure - This method will overwrite any existing comment on the test run Raises: Exception: If there is an error making the GraphQL request or processing the response. The exception is caught and logged, and the method returns None.

def add_evidence_to_test_run(self, test_run_id: str, evidence_path: str) -> bool:
2216    def add_evidence_to_test_run(self, test_run_id: str, evidence_path: str) -> bool:
2217        """Add evidence (attachments) to a test run in Xray.
2218        This method allows attaching files as evidence to a specific test run. The file is
2219        read, converted to base64, and uploaded to Xray with appropriate MIME type detection.
2220        Parameters
2221        ----------
2222        test_run_id : str
2223            The unique identifier of the test run to add evidence to
2224        evidence_path : str
2225            The local file system path to the evidence file to be attached
2226        Returns
2227        -------
2228        bool
2229            True if the evidence was successfully added, None if the operation failed.
2230            Returns None in the following cases:
2231            - Test run ID is not provided
2232            - Evidence path is not provided
2233            - Evidence file does not exist
2234            - GraphQL request fails
2235            - Any other error occurs during processing
2236        Examples
2237        --------
2238        >>> client = XrayGraphQL()
2239        >>> success = client.add_evidence_to_test_run(
2240        ...     test_run_id="10001",
2241        ...     evidence_path="/path/to/screenshot.png"
2242        ... )
2243        >>> print(success)
2244        True
2245        Notes
2246        -----
2247        - The evidence file must exist and be accessible
2248        - The file is automatically converted to base64 for upload
2249        - MIME type is automatically detected, defaults to "text/plain" if detection fails
2250        - The method supports various file types (images, documents, logs, etc.)
2251        - Failed operations are logged with appropriate error messages
2252        """
2253        try:
2254            if not test_run_id:
2255                logger.error("Test run ID is required")
2256                return None
2257            if not evidence_path:
2258                logger.error("Evidence path is required")
2259                return None
2260            if not os.path.exists(evidence_path):
2261                logger.error(f"Evidence file not found: {evidence_path}")
2262                return None
2263            #if file exists then read the file in base64
2264            evidence_base64 = None
2265            mime_type = None
2266            filename = os.path.basename(evidence_path)
2267            with open(evidence_path, "rb") as file:
2268                evidence_data = file.read()
2269                evidence_base64 = base64.b64encode(evidence_data).decode('utf-8')
2270                mime_type = mimetypes.guess_type(evidence_path)[0]
2271                logger.info(f"For loop -- Mime type: {mime_type}")
2272                if not mime_type:
2273                    mime_type = "text/plain"
2274            query = """
2275            mutation AddEvidenceToTestRun($testRunId: String!, $filename: String!, $mimeType: String!, $evidenceBase64: String!) {
2276                addEvidenceToTestRun(
2277                    id: $testRunId, 
2278                    evidence: [
2279                        {
2280                            filename : $filename,
2281                            mimeType : $mimeType,
2282                            data : $evidenceBase64
2283                        }
2284                    ]
2285                ) {
2286                    addedEvidence
2287                    warnings
2288                }
2289            }
2290            """
2291            variables = {
2292                "testRunId": test_run_id,
2293                "filename": filename,
2294                "mimeType": mime_type,
2295                "evidenceBase64": evidence_base64
2296            }
2297            data = self._make_graphql_request(query, variables) 
2298            if not data:
2299                logger.error(f"Failed to add evidence to test run: {data}")
2300                return None
2301            return data['addEvidenceToTestRun'] 
2302        except Exception as e:
2303            logger.error(f"Error adding evidence to test run: {str(e)}")
2304            logger.traceback(e)
2305            return None

Add evidence (attachments) to a test run in Xray. This method allows attaching files as evidence to a specific test run. The file is read, converted to base64, and uploaded to Xray with appropriate MIME type detection.

Parameters

test_run_id : str The unique identifier of the test run to add evidence to evidence_path : str The local file system path to the evidence file to be attached

Returns

bool True if the evidence was successfully added, None if the operation failed. Returns None in the following cases: - Test run ID is not provided - Evidence path is not provided - Evidence file does not exist - GraphQL request fails - Any other error occurs during processing

Examples

>>> client = XrayGraphQL()
>>> success = client.add_evidence_to_test_run(
...     test_run_id="10001",
...     evidence_path="/path/to/screenshot.png"
... )
>>> print(success)
True
<h2 id="notes">Notes</h2>
  • The evidence file must exist and be accessible
  • The file is automatically converted to base64 for upload
  • MIME type is automatically detected, defaults to "text/plain" if detection fails
  • The method supports various file types (images, documents, logs, etc.)
  • Failed operations are logged with appropriate error messages
def create_defect_from_test_run( self, test_run_id: str, project_key: str, parent_issue_key: str, defect_summary: str, defect_description: str) -> Optional[Dict]:
2307    def create_defect_from_test_run(self, test_run_id: str, project_key: str, parent_issue_key: str, defect_summary: str, defect_description: str) -> Optional[Dict]:
2308        """Create a defect from a test run and link it to the test run in Xray.
2309        This method performs two main operations:
2310        1. Creates a new defect in JIRA with the specified summary and description
2311        2. Links the created defect to the specified test run in Xray
2312        Parameters
2313        ----------
2314        test_run_id : str
2315            The ID of the test run to create defect from
2316        project_key : str
2317            The JIRA project key where the defect should be created.
2318            If not provided, defaults to "EAGVAL"
2319        parent_issue_key : str
2320            The JIRA key of the parent issue to link the defect to
2321        defect_summary : str
2322            Summary/title of the defect.
2323            If not provided, defaults to "Please provide a summary for the defect"
2324        defect_description : str
2325            Description of the defect.
2326            If not provided, defaults to "Please provide a description for the defect"
2327        Returns
2328        -------
2329        Optional[Dict]
2330            Response data from the GraphQL API if successful, None if failed.
2331            The response includes:
2332            - addedDefects: List of added defects
2333            - warnings: Any warnings from the operation
2334        Examples
2335        --------
2336        >>> client = XrayGraphQL()
2337        >>> result = client.create_defect_from_test_run(
2338        ...     test_run_id="10001",
2339        ...     project_key="PROJ",
2340        ...     parent_issue_key="PROJ-456",
2341        ...     defect_summary="Test failure in login flow",
2342        ...     defect_description="The login button is not responding to clicks"
2343        ... )
2344        >>> print(result)
2345        {
2346            'addedDefects': ['PROJ-123'],
2347            'warnings': []
2348        }
2349        Notes
2350        -----
2351        - The project_key will be split on '-' and only the first part will be used
2352        - The defect will be created with issue type 'Bug'
2353        - The method handles missing parameters with default values
2354        - The parent issue must exist and be accessible to create the defect
2355        """
2356        try:
2357            if not project_key:
2358                project_key = "EAGVAL"
2359            if not defect_summary:
2360                defect_summary = "Please provide a summary for the defect"
2361            if not defect_description:
2362                defect_description = "Please provide a description for the defect"
2363            project_key = project_key.split("-")[0]
2364            # Fix: Correct parameter order for create_issue
2365            defect_key, defect_id = self.create_issue(
2366                project_key=project_key,
2367                parent_issue_key=parent_issue_key,
2368                summary=defect_summary,
2369                description=defect_description,
2370                issue_type='Bug'
2371            )
2372            if not defect_key:
2373                logger.error("Failed to create defect issue")
2374                return None
2375            # Then add the defect to the test run
2376            add_defect_mutation = """
2377            mutation AddDefectsToTestRun(
2378                $testRunId: String!,
2379                $defectKey: String!
2380            ) {
2381                addDefectsToTestRun( id: $testRunId, issues: [$defectKey]) {
2382                    addedDefects
2383                    warnings
2384                }
2385            }
2386            """
2387            variables = {
2388                "testRunId": test_run_id,
2389                "defectKey": defect_key
2390            }
2391            data = None
2392            retry_count = 0
2393            while retry_count < 3:
2394                data = self._make_graphql_request(add_defect_mutation, variables)
2395                if not data:
2396                    logger.error(f"Failed to add defect [{defect_key}] to test run - {test_run_id}.. retrying... {retry_count}")
2397                    retry_count += 1
2398                    time.sleep(1)
2399                else:
2400                    break
2401            return data
2402        except Exception as e:
2403            logger.error(f"Error creating defect from test run: {str(e)}")
2404            logger.traceback(e)
2405            return None

Create a defect from a test run and link it to the test run in Xray. This method performs two main operations:

  1. Creates a new defect in JIRA with the specified summary and description
  2. Links the created defect to the specified test run in Xray

Parameters

test_run_id : str The ID of the test run to create defect from project_key : str The JIRA project key where the defect should be created. If not provided, defaults to "EAGVAL" parent_issue_key : str The JIRA key of the parent issue to link the defect to defect_summary : str Summary/title of the defect. If not provided, defaults to "Please provide a summary for the defect" defect_description : str Description of the defect. If not provided, defaults to "Please provide a description for the defect"

Returns

Optional[Dict] Response data from the GraphQL API if successful, None if failed. The response includes: - addedDefects: List of added defects - warnings: Any warnings from the operation

Examples

>>> client = XrayGraphQL()
>>> result = client.create_defect_from_test_run(
...     test_run_id="10001",
...     project_key="PROJ",
...     parent_issue_key="PROJ-456",
...     defect_summary="Test failure in login flow",
...     defect_description="The login button is not responding to clicks"
... )
>>> print(result)
{
    'addedDefects': ['PROJ-123'],
    'warnings': []
}
<h2 id="notes">Notes</h2>
  • The project_key will be split on '-' and only the first part will be used
  • The defect will be created with issue type 'Bug'
  • The method handles missing parameters with default values
  • The parent issue must exist and be accessible to create the defect
def get_test_run_comment(self, test_run_id: str) -> Optional[str]:
2407    def get_test_run_comment(self, test_run_id: str) -> Optional[str]:
2408        """
2409        Retrieve the comment of a specific test run from Xray using the GraphQL API.
2410        This method allows retrieving the comment associated with a test run
2411        identified by its ID. The comment can provide additional context, test results,
2412        or any other relevant information about the test execution.
2413        Args:
2414            test_run_id (str): The unique identifier of the test run to retrieve comment from.
2415                This is the internal Xray ID for the test run, not the Jira issue key.
2416        Returns:
2417            Optional[str]: The comment text of the test run if successful, None if:
2418                - The test run ID is not found
2419                - The GraphQL request fails
2420                - No comment exists for the test run
2421                - Any other error occurs during the API request
2422        Example:
2423            >>> client = XrayGraphQL()
2424            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2425            >>> comment = client.get_test_run_comment(test_run_id)
2426            >>> print(comment)
2427            "Test passed with performance within expected range"
2428        Note:
2429            - The test run ID must be valid and accessible with current authentication
2430            - If no comment exists for the test run, the method will return None
2431            - Failed requests are logged as errors with details about the failure
2432            - The method returns the raw comment text as stored in Xray
2433        Raises:
2434            Exception: If there is an error making the GraphQL request or processing the response.
2435                The exception is caught and logged, and the method returns None.
2436        """
2437        try:
2438            # Try the direct ID approach first
2439            query = """
2440            query GetTestRunComment($testRunId: String!) {
2441                getTestRunById(id: $testRunId) {
2442                    id
2443                    comment
2444                    status {
2445                        name
2446                    }
2447                }
2448            }                       
2449            """
2450            variables = {
2451                "testRunId": test_run_id
2452            }
2453            data = self._make_graphql_request(query, variables)
2454            jprint(data)
2455            if not data:
2456                logger.error(f"Failed to get test run comment for test run {test_run_id}")
2457                return None
2458            test_run = data.get('getTestRunById', {})
2459            if not test_run:
2460                logger.warning(f"No test run found with ID {test_run_id}")
2461                return None
2462            comment = test_run.get('comment')
2463            return comment
2464        except Exception as e:
2465            logger.error(f"Error getting test run comment: {str(e)}")
2466            logger.traceback(e)
2467            return None

Retrieve the comment of a specific test run from Xray using the GraphQL API. This method allows retrieving the comment associated with a test run identified by its ID. The comment can provide additional context, test results, or any other relevant information about the test execution. Args: test_run_id (str): The unique identifier of the test run to retrieve comment from. This is the internal Xray ID for the test run, not the Jira issue key. Returns: Optional[str]: The comment text of the test run if successful, None if: - The test run ID is not found - The GraphQL request fails - No comment exists for the test run - Any other error occurs during the API request Example:

client = XrayGraphQL() test_run_id = "67fcfd4b9e6d63d4c1d57b32" comment = client.get_test_run_comment(test_run_id) print(comment) "Test passed with performance within expected range" Note: - The test run ID must be valid and accessible with current authentication - If no comment exists for the test run, the method will return None - Failed requests are logged as errors with details about the failure - The method returns the raw comment text as stored in Xray Raises: Exception: If there is an error making the GraphQL request or processing the response. The exception is caught and logged, and the method returns None.

def append_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2469    def append_test_run_comment(self, test_run_id: str, test_run_comment: str) -> bool:
2470        """
2471        Append the comment of a specific test run in Xray using the GraphQL API.
2472        This method allows appending the comment associated with a test run
2473        identified by its ID. The comment can provide additional context, test results,
2474        or any other relevant information about the test execution.
2475        Args:
2476            test_run_id (str): The unique identifier of the test run to update.
2477                This is the internal Xray ID for the test run, not the Jira issue key.
2478            test_run_comment (str): The comment text to append to the test run.
2479                This will be added to any existing comment on the test run with proper formatting.
2480        Returns:
2481            bool: True if the comment update was successful, False otherwise.
2482                Returns None if an error occurs during the API request.
2483        Example:
2484            >>> client = XrayGraphQL()
2485            >>> test_run_id = "67fcfd4b9e6d63d4c1d57b32"
2486            >>> success = client.append_test_run_comment(
2487            ...     test_run_id,
2488            ...     "Test passed with performance within expected range"
2489            ... )
2490            >>> print(success)
2491            True
2492        Note:
2493            - The test run ID must be valid and accessible with current authentication
2494            - The comment can include any text content, including newlines and special characters
2495            - Failed updates are logged as errors with details about the failure
2496            - This method will append to existing comments with proper line breaks
2497            - If no existing comment exists, the new comment will be set as the initial comment
2498        Raises:
2499            Exception: If there is an error making the GraphQL request or processing the response.
2500                The exception is caught and logged, and the method returns None.
2501        """
2502        try:
2503            # Get existing comment
2504            existing_comment = self.get_test_run_comment(test_run_id)
2505            # Prepare the combined comment with proper formatting
2506            if existing_comment:
2507                # If there's an existing comment, append with double newline for proper separation
2508                combined_comment = f"{existing_comment}\n{test_run_comment}"
2509            else:
2510                # If no existing comment, use the new comment as is
2511                combined_comment = test_run_comment
2512                logger.debug(f"No existing comment found for test run {test_run_id}, setting initial comment")
2513            query = """
2514            mutation UpdateTestRunComment($testRunId: String!, $comment: String!) {
2515                updateTestRunComment(
2516                    id: $testRunId, 
2517                    comment: $comment 
2518                ) 
2519            }                       
2520            """
2521            variables = {
2522                "testRunId": test_run_id,
2523                "comment": combined_comment
2524            }
2525            data = self._make_graphql_request(query, variables)
2526            if not data:
2527                logger.error(f"Failed to update test run comment for test run {test_run_id}")
2528                return None
2529            return data['updateTestRunComment']
2530        except Exception as e:
2531            logger.error(f"Error updating test run comment: {str(e)}")
2532            logger.traceback(e)
2533            return None

Append the comment of a specific test run in Xray using the GraphQL API. This method allows appending the comment associated with a test run identified by its ID. The comment can provide additional context, test results, or any other relevant information about the test execution. Args: test_run_id (str): The unique identifier of the test run to update. This is the internal Xray ID for the test run, not the Jira issue key. test_run_comment (str): The comment text to append to the test run. This will be added to any existing comment on the test run with proper formatting. Returns: bool: True if the comment update was successful, False otherwise. Returns None if an error occurs during the API request. Example:

client = XrayGraphQL() test_run_id = "67fcfd4b9e6d63d4c1d57b32" success = client.append_test_run_comment( ... test_run_id, ... "Test passed with performance within expected range" ... ) print(success) True Note: - The test run ID must be valid and accessible with current authentication - The comment can include any text content, including newlines and special characters - Failed updates are logged as errors with details about the failure - This method will append to existing comments with proper line breaks - If no existing comment exists, the new comment will be set as the initial comment Raises: Exception: If there is an error making the GraphQL request or processing the response. The exception is caught and logged, and the method returns None.

def download_attachment(self, jira_key: str, file_extension: str) -> Optional[Dict]:
2535    def download_attachment(self, jira_key: str, file_extension: str) -> Optional[Dict]:
2536        '''
2537        Download a JIRA attachment by its ID.
2538        '''
2539        try:
2540            response = self.make_jira_request(jira_key, 'GET')
2541            
2542            if not response or 'fields' not in response:
2543                logger.error(f"Error: Could not retrieve issue {jira_key}")
2544                return None
2545            
2546            # Find attachment by filename
2547            attachments = response.get('fields', {}).get('attachment', [])
2548            target_attachment = [att for att in attachments if att.get('filename').endswith(file_extension)]
2549            if not target_attachment:
2550                logger.error(f"No attachment found for {jira_key} with extension {file_extension}")
2551                return None
2552            
2553            combined_attachment = []
2554            for attachment in target_attachment:
2555                attachment_id = attachment.get('id')
2556                mime_type = attachment.get('mimeType', '')
2557                combined_attachment.append(self.download_jira_attachment_by_id(attachment_id, mime_type))
2558            
2559            return combined_attachment
2560        except Exception as e:
2561            logger.error(f"Error downloading JIRA attachment: {str(e)}")
2562            logger.traceback(e)
2563            return None

Download a JIRA attachment by its ID.