"""Jira connector for CRUD operations (neutralized to generic ticket interface). This module defines its own minimal abstractions to avoid coupling. """ import logging import aiohttp import asyncio import json from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute logger = logging.getLogger(__name__) class ConnectorTicketJira(TicketBase): def __init__( self, *, apiUsername: str, apiToken: str, apiUrl: str, projectCode: str, ticketType: str, ) -> None: self.apiUsername = apiUsername self.apiToken = apiToken self.apiUrl = apiUrl self.projectCode = projectCode self.ticketType = ticketType async def read_attributes(self) -> list[TicketFieldAttribute]: """ Read field attributes from Jira by querying for a single issue and extracting the field mappings. Returns: list[TicketFieldAttribute]: List of field attributes with names and IDs """ # Build JQL dynamically; allow empty or '*' issue_type to mean "all types" if self.ticketType and self.ticketType != "*": jql_query = f"project={self.projectCode} AND issuetype={self.ticketType}" else: jql_query = f"project={self.projectCode}" # Prepare the request URL (use JQL search endpoint) url = f"{self.apiUrl}/rest/api/3/search/jql" # Prepare authentication auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken) try: async with aiohttp.ClientSession() as session: headers = {"Content-Type": "application/json"} payload = { "jql": jql_query, "maxResults": 1 # Don't specify fields to get all available fields } async with session.post(url, json=payload, auth=auth, headers=headers) as response: if response.status != 200: error_text = await response.text() logger.error( f"Jira API request failed with status {response.status}: {error_text}" ) raise Exception( f"Jira API request failed with status {response.status}" ) data = await response.json() # Extract issues and field names issues = data.get("issues", []) field_names = data.get("names", {}) # If no issues or fields are present, fall back to the fields API if not issues or not issues[0].get("fields"): logger.warning( "No issue fields returned by search; falling back to /rest/api/3/field" ) return await self._read_all_fields_via_fields_api() # Extract field attributes from the first issue attributes = [] issue = issues[0] fields = issue.get("fields", {}) for field_id, value in fields.items(): fieldName = field_names.get(field_id, field_id) attributes.append( TicketFieldAttribute(fieldName=fieldName, field=field_id) ) return attributes except aiohttp.ClientError as e: logger.error(f"HTTP client error while fetching Jira attributes: {str(e)}") raise Exception(f"Failed to connect to Jira: {str(e)}") except json.JSONDecodeError as e: logger.error(f"Failed to parse Jira API response: {str(e)}") raise Exception(f"Invalid response from Jira API: {str(e)}") except Exception as e: logger.error(f"Unexpected error while fetching Jira attributes: {str(e)}") raise async def _read_all_fields_via_fields_api(self) -> list[TicketFieldAttribute]: """Fallback: use Jira fields API to list all fields with id->name mapping.""" auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken) url = f"{self.apiUrl}/rest/api/3/field" try: async with aiohttp.ClientSession() as session: async with session.get(url, auth=auth) as response: if response.status != 200: error_text = await response.text() logger.error( f"Jira fields API failed with status {response.status}: {error_text}" ) return [] data = await response.json() attributes: list[TicketFieldAttribute] = [] for field in data: field_id = field.get("id") fieldName = field.get("name", field_id) if field_id: attributes.append( TicketFieldAttribute(fieldName=fieldName, field=field_id) ) return attributes except Exception as e: logger.error(f"Error while calling fields API: {str(e)}") return [] async def read_tasks(self, *, limit: int = 0) -> list[dict]: """ Read tasks from Jira with pagination support. Args: limit: Maximum number of tasks to retrieve. 0 means no limit. Returns: list[dict]: List of tasks with their data """ # Build JQL dynamically; allow empty or '*' issue_type to mean "all types" if self.ticketType and self.ticketType != "*": jql_query = f"project={self.projectCode} AND issuetype={self.ticketType}" else: jql_query = f"project={self.projectCode}" # Initialize variables for pagination (cursor-based /search/jql) max_results = 100 next_page_token: str | None = None tasks: list[dict] = [] page_counter = 0 max_pages_safety_cap = 1000 seen_issue_ids: set[str] = set() # Prepare authentication auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken) url = f"{self.apiUrl}/rest/api/3/search/jql" try: async with aiohttp.ClientSession() as session: while True: # Prepare request payload for JQL search with cursor-based pagination # According to Jira API docs, BOTH jql AND nextPageToken should be included in subsequent requests payload = { "jql": jql_query, "maxResults": max_results, "fields": ["*all"] # Get all fields } if next_page_token: # For subsequent pages, include BOTH jql and nextPageToken payload["nextPageToken"] = next_page_token headers = {"Content-Type": "application/json"} async with session.post( url, json=payload, auth=auth, headers=headers ) as response: if response.status != 200: error_text = await response.text() logger.error( f"Failed to fetch tasks from Jira. Status code: {response.status}, Response: {error_text}" ) break data = await response.json() # Handle cursor-based pagination response issues = data.get("issues", []) is_last = data.get("isLast", False) current_next_page_token = data.get("nextPageToken") # Debug: log pagination info logger.debug(f"Pagination info - Issues: {len(issues)}, isLast: {is_last}, nextPageToken: {current_next_page_token[:50] if current_next_page_token else 'None'}...") new_items_added = 0 for issue in issues: # Store the raw JIRA issue data directly # This matches what the reference implementation expects issue_id = issue.get("id") or issue.get("key") if issue_id and issue_id in seen_issue_ids: continue if issue_id: seen_issue_ids.add(issue_id) tasks.append(issue) new_items_added += 1 # Check limit if limit > 0 and len(tasks) >= limit: break logger.debug(f"Issues packages reading: {len(tasks)}") # Stop conditions # 1) No issues returned if len(issues) == 0: break # 1b) No new items added (duplicate page) -> prevent endless loop if new_items_added == 0: logger.warning("Pagination returned duplicate page; stopping to prevent loop") break # 2) Cursor-based pagination says last page if is_last: break # 3) Safety cap to avoid endless loops page_counter += 1 if page_counter >= max_pages_safety_cap: logger.warning("Stopping pagination due to safety cap") break # 4) Continue to next page if we have a nextPageToken if not current_next_page_token: logger.warning("No nextPageToken available, stopping pagination") break # Update the token for the next iteration next_page_token = current_next_page_token # Add a small delay to avoid token expiration issues await asyncio.sleep(0.1) logger.info(f"JIRA issues read: {len(tasks)} (cursor-based pagination)") return tasks except aiohttp.ClientError as e: logger.error(f"HTTP client error while fetching Jira tasks: {str(e)}") raise Exception(f"Failed to connect to Jira: {str(e)}") except json.JSONDecodeError as e: logger.error(f"Failed to parse Jira API response: {str(e)}") raise Exception(f"Invalid response from Jira API: {str(e)}") except Exception as e: logger.error(f"Unexpected error while fetching Jira tasks: {str(e)}") raise async def write_tasks(self, tasklist: list[dict]) -> None: """ Write/update tasks to Jira. Args: tasklist: List of dicts containing task data to update """ headers = {"Accept": "application/json", "Content-Type": "application/json"} auth = aiohttp.BasicAuth(self.apiUsername, self.apiToken) try: async with aiohttp.ClientSession() as session: for task_data in tasklist: task_id = ( task_data.get("ID") or task_data.get("id") or task_data.get("key") ) if not task_id: logger.warning("Ticket update missing ID or key, skipping") continue # Extract fields to update from task data # The task data should contain the field updates in a "fields" key fields = task_data.get("fields", {}) if not fields: logger.debug(f"No fields to update for task {task_id}") continue # Convert ADF fields to proper format processed_fields = {} for field_id, field_value in fields.items(): if field_id == "customfield_10168": # Convert to ADF format for paragraph fields if isinstance(field_value, str) and field_value.strip(): processed_fields[field_id] = { "type": "doc", "version": 1, "content": [ { "type": "paragraph", "content": [ { "type": "text", "text": field_value } ] } ] } else: # Skip empty ADF fields continue else: processed_fields[field_id] = field_value if not processed_fields: logger.debug(f"No valid fields to update for task {task_id}") continue # Prepare update data update_data = {"fields": processed_fields} # Make the update request url = f"{self.apiUrl}/rest/api/3/issue/{task_id}" async with session.put( url, json=update_data, headers=headers, auth=auth ) as response: if response.status == 204: pass else: error_text = await response.text() logger.error( f"JIRA failed to update task {task_id}: {response.status} - {error_text}" ) except aiohttp.ClientError as e: logger.error(f"HTTP client error while updating Jira tasks: {str(e)}") raise Exception(f"Failed to connect to Jira: {str(e)}") except Exception as e: logger.error(f"Unexpected error while updating Jira tasks: {str(e)}") raise