"""CLI entry point for querying Jira offboarding and suspend tickets.""" import json import logging from typing import Annotated import typer from config import JiraConfig from jira_client.constants import JQLQueries, TicketValidation from jira_client.ticket_types import OFFBOARDING, SUSPENSION, TicketType from requests.exceptions import HTTPError from jira_client import JiraClient, SearchUtils, TextUtils LOGGER = logging.getLogger('jira_client.cli') app: typer.Typer = typer.Typer(help='Jira CLI tool.') @app.callback() def callback( debug: Annotated[ bool, typer.Option('--debug/--no-debug', help='Show debug logs.') ] = False, ) -> None: """Configure logging for the Jira CLI.""" level = logging.DEBUG if debug else logging.INFO logging.basicConfig( level=level, format='%(asctime)s %(name)s %(levelname)s: %(message)s' ) def _build_client() -> JiraClient: """Build a JiraClient from the environment, exiting on config errors. :return: A configured JiraClient. """ try: cfg = JiraConfig() except Exception as exc: LOGGER.error('Configuration error: %s', exc) raise typer.Exit(code=1) return JiraClient(cfg) def _flatten_description(ticket: dict) -> str | None: """Flatten a raw ticket's ADF description into a single text string. :param ticket: A raw Jira ticket dict from the search API. :return: The joined paragraph text, or ``None`` if the ticket has no description / content. """ description = ticket['fields'].get('description') if description is None: LOGGER.error('Ticket %s has no description field.', ticket['key']) return None content_blocks = description.get('content') if content_blocks is None: LOGGER.error('Ticket %s has no description content.', ticket['key']) return None text_parts = [ node['text'] for block in content_blocks if block.get('type') == 'paragraph' for node in block.get('content', []) if node.get('type') == 'text' and node.get('text') ] return ' '.join(text_parts) def _query_and_print(ticket_type: TicketType, jql: str) -> None: """Query Jira for *ticket_type* tickets via *jql*, parse, and print JSON. Detection and parsing are driven by *ticket_type* (its sentinel/full-name regexes and date extractor), mirroring ``app._handle_query_tickets`` so the CLI exercises the same logic as the Lambda handler. :param ticket_type: The ticket-type config driving detection and parsing. :param jql: The JQL query string to execute. """ client = _build_client() text_utils = TextUtils() search_utils = SearchUtils() try: tickets = client.query_jira_tickets(jql) except HTTPError as http_err: status = http_err.response.status_code if http_err.response else None if status == 401: LOGGER.error( 'Authentication failed when querying Jira tickets. ' 'Please check your JIRA_USER_EMAIL and JIRA_API_TOKEN configuration.' ) elif status == 403: LOGGER.error( 'Access denied when querying Jira tickets. ' 'Please verify your JIRA_USER_EMAIL has proper access to the Jira instance.' ) else: LOGGER.error('Failed to query Jira tickets: %s', http_err) raise typer.Exit(code=1) except Exception as exc: LOGGER.error('Failed to query Jira tickets: %s', exc) raise typer.Exit(code=1) result: dict[str, list] = {'records': []} for ticket in tickets: text = _flatten_description(ticket) if text is None: continue # Jira's `textfields ~ ...` filter is a tokenised full-text search, so a # suspend ticket can match the offboarding JQL (and vice versa). The # sentinel regex is the authoritative type check, mirroring # ``app._handle_query_tickets``. if not ticket_type.sentinel_re.search(text): LOGGER.warning( 'Ticket %s: description is not a %s request — skipping.', ticket['key'], ticket_type.name, ) continue clean = text_utils.clean_text(text) meta: dict[str, str] = {} emails = search_utils.find_all_emails(clean) if emails: meta['email'] = emails[0] LOGGER.info(' Found email: %s', emails[0]) else: LOGGER.error('No email found in the description.') full_name = search_utils.find_full_name(clean, ticket_type.full_name_re) if full_name: meta['full_name'] = ' '.join( word.capitalize() for word in full_name.split() ) LOGGER.info(' Found full name: %s', meta['full_name']) else: LOGGER.error('No full name found in the description.') effective_date = ticket_type.date_extractor(clean) if effective_date: meta['last_working_day'] = effective_date LOGGER.info(' Found effective date: %s', effective_date) else: LOGGER.error('No effective date found in the description.') result['records'].append({'id': ticket['key'], 'text': clean, 'meta': meta}) print(json.dumps(result, indent=2)) @app.command() def query_offboarding_tickets() -> None: """Query and parse new open offboarding tickets, printing parsed results.""" _query_and_print(OFFBOARDING, OFFBOARDING.jql_new) @app.command() def query_approved_tickets() -> None: """Query offboarding tickets labelled 'approved-for-offboarding'.""" _query_and_print(OFFBOARDING, OFFBOARDING.jql_approved) @app.command() def query_suspend_tickets() -> None: """Query and parse new open suspend tickets, printing parsed results.""" _query_and_print(SUSPENSION, SUSPENSION.jql_new) @app.command() def query_approved_suspend_tickets() -> None: """Query suspend tickets labelled 'approved-for-suspension'.""" _query_and_print(SUSPENSION, SUSPENSION.jql_approved) @app.command() def query_completed_tickets() -> None: """Query SYS tickets labelled complete but not yet Closed (key only). Mirrors the 'query-completed-tickets' action: no description parsing, since closing needs only the issue key. """ client = _build_client() try: raw_tickets = client.query_jira_tickets(JQLQueries.COMPLETED_TICKETS) except Exception as exc: LOGGER.error('Failed to query completed tickets: %s', exc) raise typer.Exit(code=1) tickets = [{'id': t['key']} for t in raw_tickets if t.get('key')] print(json.dumps({'tickets': tickets}, indent=2)) @app.command() def validate_ticket( ticket_id: Annotated[ str, typer.Option( '--ticket-id', help='Jira ticket key to validate (e.g. SYS-1234).' ), ], ) -> None: """Validate that a Jira ticket originated from the expected helpdesk source.""" client = _build_client() try: fields = client.get_ticket_fields(ticket_id, 'reporter,description') except Exception as exc: LOGGER.error('Failed to fetch ticket fields for %s: %s', ticket_id, exc) raise typer.Exit(code=1) warnings: list[str] = [] reporter = fields.get('reporter') or {} reporter_email = reporter.get('emailAddress', '') if reporter_email not in TicketValidation.REPORTER_ALLOWLIST: warnings.append( f'Reporter email {reporter_email!r} is not in the expected allowlist. ' f'Expected one of: {sorted(TicketValidation.REPORTER_ALLOWLIST)}.' ) description = fields.get('description') or {} content_blocks = description.get('content') or [] text_parts = [ node['text'] for block in content_blocks if block.get('type') == 'paragraph' for node in block.get('content', []) if node.get('type') == 'text' and node.get('text') ] combined_text = ' '.join(text_parts) if TicketValidation.FOOTER_SENTINEL not in combined_text: warnings.append( f'Description does not contain the expected footer sentinel ' f'({TicketValidation.FOOTER_SENTINEL!r}). ' f'The ticket may not have been created via the expected helpdesk email.' ) elif TicketValidation.FOOTER_EMAIL not in combined_text: warnings.append( f'Footer sentinel found but expected helpdesk address ' f'{TicketValidation.FOOTER_EMAIL!r} is missing.' ) print( json.dumps( {'ticket_id': ticket_id, 'valid': len(warnings) == 0, 'warnings': warnings}, indent=2, ) ) @app.command() def close_ticket( ticket_id: Annotated[ str, typer.Option('--ticket-id', help='Jira ticket key to close (e.g. SYS-1234).'), ], target_status: Annotated[ str, typer.Option('--target-status', help='Destination workflow status name.'), ] = 'Closed', dry_run: Annotated[ bool, typer.Option('--dry-run/--no-dry-run', help='Skip the transition API call.'), ] = False, ) -> None: """Transition a Jira ticket to its closed/done status.""" client = _build_client() if dry_run: LOGGER.info( '[Dry run] Would close ticket %s (target status %r).', ticket_id, target_status, ) print( json.dumps( {'ticket_id': ticket_id, 'dry_run': True, 'closed': False}, indent=2 ) ) return try: client.close_ticket(ticket_id, target_status) except HTTPError as http_err: status = http_err.response.status_code if http_err.response else None if status == 401: LOGGER.error( 'Authentication failed when closing ticket %s. ' 'Please check your JIRA_USER_EMAIL and JIRA_API_TOKEN configuration.', ticket_id, ) elif status == 403: LOGGER.error( 'Access denied when closing ticket %s. ' 'Please verify your JIRA_USER_EMAIL has proper access to the Jira instance.', ticket_id, ) else: LOGGER.error('Failed to close ticket %s: %s', ticket_id, http_err) raise typer.Exit(code=1) except Exception as exc: LOGGER.error('Failed to close ticket %s: %s', ticket_id, exc) raise typer.Exit(code=1) print( json.dumps({'ticket_id': ticket_id, 'dry_run': False, 'closed': True}, indent=2) ) if __name__ == '__main__': app()