"""Jira API client.""" import json import logging import re import requests from config import JiraConfig from requests.adapters import HTTPAdapter from requests.auth import HTTPBasicAuth from requests.exceptions import HTTPError from urllib3.util.retry import Retry LOGGER = logging.getLogger(__name__) class JiraClient: """Thin wrapper around the Jira API.""" _TICKET_ID_RE = re.compile(r'^[A-Z][A-Z0-9]+-\d+$') def __init__(self, cfg: JiraConfig): """Initialize the JiraClient.""" self.cfg = cfg self._session = requests.Session() retries = Retry( total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504] ) self._session.mount('https://', HTTPAdapter(max_retries=retries)) self._session.auth = HTTPBasicAuth( self.cfg.jira_user_email, self.cfg.jira_api_token ) self._session.headers.update({'Accept': 'application/json'}) def _validate_ticket_id(self, ticket_id: str) -> None: """Validate that a ticket ID matches the expected Jira format. :param ticket_id: The Jira issue key to validate. :raises ValueError: If the ticket ID format is invalid. """ if not self._TICKET_ID_RE.match(ticket_id): raise ValueError( f'Invalid Jira ticket ID format: {ticket_id!r}. ' f'Expected format: PROJECT-123' ) def _raise_on_auth_error(self, resp: requests.Response) -> None: """Raise a descriptive HTTPError for 401 or 403 responses. :param resp: The HTTP response to inspect. :raises HTTPError: If status code is 401 or 403. """ if resp.status_code == 401: msg = ( 'Jira authentication failed: Invalid credentials or expired API token. ' 'Please verify your JIRA_USER_EMAIL and JIRA_API_TOKEN configuration.' ) LOGGER.error(msg) raise HTTPError(msg, response=resp) if resp.status_code == 403: msg = ( 'Jira access denied: Insufficient permissions. ' 'Please verify your JIRA_USER_EMAIL has proper access to the Jira instance.' ) LOGGER.error(msg) raise HTTPError(msg, response=resp) def query_jira_tickets(self, jql_query: str) -> list[dict]: """Query Jira jira_tickets using JQL. :param jql_query: The JQL query string. :return: List of jira_tickets matching the query. :raises HTTPError: If the request fails with a non-2xx status code :raises requests.exceptions.RequestException: If there's a network or other request error """ url = f'{self.cfg.jira_base_url}/rest/api/3/search/jql' query = { 'jql': jql_query, 'fields': 'id,summary,description,key', 'maxResults': '500', } try: resp = self._session.get(url, params=query, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() except HTTPError as http_err: LOGGER.error('Jira API request failed with HTTP error: %s', http_err) raise except requests.exceptions.RequestException as req_err: LOGGER.error('Jira API request failed with network error: %s', req_err) raise try: data = resp.json() except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as exc: raise RuntimeError( f'Jira API returned non-JSON response (status {resp.status_code})' ) from exc jira_tickets = data.get('issues', []) return jira_tickets def get_ticket_fields(self, ticket_id: str, fields: str) -> dict: """Fetch specific fields for a single Jira ticket. :param ticket_id: The Jira issue key (e.g. ``SYS-4821``). :param fields: Comma-separated field names to retrieve. :return: The ``fields`` dict from the Jira issue response. :raises HTTPError: If the request fails with a non-2xx status code. :raises requests.exceptions.RequestException: If there is a network error. :raises ValueError: If ticket_id format is invalid. """ self._validate_ticket_id(ticket_id) url = f'{self.cfg.jira_base_url}/rest/api/3/issue/{ticket_id}' try: resp = self._session.get(url, params={'fields': fields}, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() except HTTPError as http_err: LOGGER.error( 'Jira API request failed fetching fields for %s: %s', ticket_id, http_err, ) raise except requests.exceptions.RequestException as req_err: LOGGER.error( 'Jira API network error fetching fields for %s: %s', ticket_id, req_err ) raise try: data = resp.json() except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as exc: raise RuntimeError( f'Jira API returned non-JSON response for {ticket_id} ' f'(status {resp.status_code})' ) from exc return data.get('fields', {}) def add_comment(self, ticket_id: str, comment: str) -> None: """Add a plain-text comment to a Jira ticket. The comment body is wrapped in a minimal Atlassian Document Format (ADF) document as required by the Jira REST API v3. :param ticket_id: The Jira issue key (e.g. ``SYS-4821``). :param comment: Plain-text comment body to post. :raises HTTPError: If the request fails with a non-2xx status code. :raises requests.exceptions.RequestException: If there is a network error. :raises ValueError: If ticket_id format is invalid. """ self._validate_ticket_id(ticket_id) url = f'{self.cfg.jira_base_url}/rest/api/3/issue/{ticket_id}/comment' body = { 'body': { 'version': 1, 'type': 'doc', 'content': [ { 'type': 'paragraph', 'content': [{'type': 'text', 'text': comment}], } ], } } try: resp = self._session.post(url, json=body, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() LOGGER.info('Added comment to Jira ticket %s.', ticket_id) except HTTPError as http_err: LOGGER.error( 'Jira API request failed adding comment to %s: %s', ticket_id, http_err ) raise except requests.exceptions.RequestException as req_err: LOGGER.error( 'Jira API network error adding comment to %s: %s', ticket_id, req_err ) raise def add_due_date(self, ticket_id: str, due_date: str) -> None: """Set the due date field on a Jira ticket. :param ticket_id: The Jira issue key (e.g. ``SYS-4821``). :param due_date: ISO-8601 date string in ``YYYY-MM-DD`` format. :raises HTTPError: If the request fails with a non-2xx status code. :raises requests.exceptions.RequestException: If there is a network error. :raises ValueError: If ticket_id format is invalid. """ self._validate_ticket_id(ticket_id) url = f'{self.cfg.jira_base_url}/rest/api/3/issue/{ticket_id}' body = {'fields': {'duedate': due_date}} try: resp = self._session.put(url, json=body, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() LOGGER.info('Set due date %s on Jira ticket %s.', due_date, ticket_id) except HTTPError as http_err: LOGGER.error( 'Jira API request failed setting due date on %s: %s', ticket_id, http_err, ) raise except requests.exceptions.RequestException as req_err: LOGGER.error( 'Jira API network error setting due date on %s: %s', ticket_id, req_err ) raise def get_transitions(self, ticket_id: str) -> list[dict]: """Fetch the available workflow transitions for a Jira ticket. :param ticket_id: The Jira issue key (e.g. ``SYS-4821``). :return: List of transition dicts, each with an ``id`` and a ``to`` status. :raises HTTPError: If the request fails with a non-2xx status code. :raises requests.exceptions.RequestException: If there is a network error. :raises ValueError: If ticket_id format is invalid. """ self._validate_ticket_id(ticket_id) url = f'{self.cfg.jira_base_url}/rest/api/3/issue/{ticket_id}/transitions' try: resp = self._session.get(url, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() except HTTPError as http_err: LOGGER.error( 'Jira API request failed fetching transitions for %s: %s', ticket_id, http_err, ) raise except requests.exceptions.RequestException as req_err: LOGGER.error( 'Jira API network error fetching transitions for %s: %s', ticket_id, req_err, ) raise try: data = resp.json() except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as exc: raise RuntimeError( f'Jira API returned non-JSON response for {ticket_id} ' f'(status {resp.status_code})' ) from exc return data.get('transitions', []) def _get_current_status(self, ticket_id: str) -> str: """Return the current workflow status name of a Jira ticket. :param ticket_id: The Jira issue key (e.g. ``SYS-4821``). :return: The current status name (e.g. ``'Closed'``), or ``''`` if the status field is absent from the response. :raises HTTPError: If the request fails with a non-2xx status code. :raises requests.exceptions.RequestException: If there is a network error. """ url = f'{self.cfg.jira_base_url}/rest/api/3/issue/{ticket_id}' try: resp = self._session.get(url, params={'fields': 'status'}, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() except HTTPError as http_err: LOGGER.error( 'Jira API request failed fetching status for %s: %s', ticket_id, http_err, ) raise except requests.exceptions.RequestException as req_err: LOGGER.error( 'Jira API network error fetching status for %s: %s', ticket_id, req_err, ) raise try: data = resp.json() except (json.JSONDecodeError, requests.exceptions.JSONDecodeError) as exc: raise RuntimeError( f'Jira API returned non-JSON response for {ticket_id} ' f'(status {resp.status_code})' ) from exc return data.get('fields', {}).get('status', {}).get('name', '') def close_ticket(self, ticket_id: str, target_status: str) -> None: """Transition a Jira ticket to *target_status* via the Jira workflow. Looks up the ticket's available transitions and executes the one whose destination status name case-insensitively matches *target_status*. Transition IDs are workflow-specific, so they are resolved by status name rather than hardcoded. :param ticket_id: The Jira issue key (e.g. ``SYS-4821``). :param target_status: The desired destination status name (e.g. ``'Closed'``). :raises HTTPError: If the request fails with a non-2xx status code. :raises requests.exceptions.RequestException: If there is a network error. :raises ValueError: If ticket_id format is invalid, or no transition to *target_status* is available for this ticket. """ self._validate_ticket_id(ticket_id) transitions = self.get_transitions(ticket_id) transition_id = None available_statuses = [] for transition in transitions: status_name = transition.get('to', {}).get('name', '') available_statuses.append(status_name) if status_name.lower() == target_status.lower(): transition_id = transition.get('id') break if transition_id is None: current_status = self._get_current_status(ticket_id) if current_status.lower() == target_status.lower(): LOGGER.info( 'Ticket %s is already at status %r — nothing to do.', ticket_id, target_status, ) return raise ValueError( f'No transition to status {target_status!r} available for ticket ' f'{ticket_id}. Current status: {current_status!r}. ' f'Available target statuses: {available_statuses}' ) url = f'{self.cfg.jira_base_url}/rest/api/3/issue/{ticket_id}/transitions' body = {'transition': {'id': transition_id}} try: resp = self._session.post(url, json=body, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() LOGGER.info( 'Transitioned Jira ticket %s to status %r.', ticket_id, target_status ) except HTTPError as http_err: LOGGER.error( 'Jira API request failed closing ticket %s: %s', ticket_id, http_err ) raise except requests.exceptions.RequestException as req_err: LOGGER.error( 'Jira API network error closing ticket %s: %s', ticket_id, req_err ) raise def add_label(self, ticket_id: str, label: str) -> None: """Append a label to a Jira ticket without overwriting existing labels. Uses the Jira ``update`` operation so the label is added additively rather than replacing the current label set. :param ticket_id: The Jira issue key (e.g. ``SYS-4821``). :param label: Label string to add (e.g. ``'automation-complete'``). :raises HTTPError: If the request fails with a non-2xx status code. :raises requests.exceptions.RequestException: If there is a network error. :raises ValueError: If ticket_id format is invalid. """ self._validate_ticket_id(ticket_id) url = f'{self.cfg.jira_base_url}/rest/api/3/issue/{ticket_id}' body = {'update': {'labels': [{'add': label}]}} try: resp = self._session.put(url, json=body, timeout=30) self._raise_on_auth_error(resp) resp.raise_for_status() LOGGER.info('Added label %r to Jira ticket %s.', label, ticket_id) except HTTPError as http_err: LOGGER.error( 'Jira API request failed adding label to %s: %s', ticket_id, http_err ) raise except requests.exceptions.RequestException as req_err: LOGGER.error( 'Jira API network error adding label to %s: %s', ticket_id, req_err ) raise