"""AWS Lambda handler for Jira offboarding ticket queries.""" import logging import os from typing import Any import sentry_sdk from config import JiraConfig from sentry_sdk.integrations.aws_lambda import AwsLambdaIntegration from shared.schemas import ( AddCommentEvent, AddCommentResponse, AddDueDateEvent, AddDueDateResponse, AddLabelEvent, AddLabelResponse, CloseTicketEvent, CloseTicketResponse, CompletedTicket, QueryApprovedSuspendTicketsEvent, QueryApprovedSuspendTicketsResponse, QueryApprovedTicketsEvent, QueryApprovedTicketsResponse, QueryCompletedTicketsEvent, QueryCompletedTicketsResponse, QueryOffboardingTicketsEvent, QueryOffboardingTicketsResponse, QuerySuspendTicketsEvent, QuerySuspendTicketsResponse, ValidateTicketEvent, ValidateTicketResponse, ) from shared.schemas.jira import Ticket from jira_client.constants import JQLQueries, TicketValidation from jira_client.jira_client import JiraClient from jira_client.ticket_types import OFFBOARDING, SUSPENSION, TicketType from jira_client.utils import SearchUtils, TextUtils sentry_sdk.init( dsn=os.environ.get('SENTRY_DSN'), environment=os.environ.get('ENVIRONMENT'), integrations=[AwsLambdaIntegration()], ) LOGGER = logging.getLogger(__name__) logging.basicConfig( level=logging.INFO, format='%(asctime)s %(name)s %(levelname)s: %(message)s' ) def _flatten_adf_content(content_blocks: list[dict]) -> str: """Extract plain text from ADF content blocks. :param content_blocks: List of ADF block nodes (paragraphs, etc.) :return: Space-joined text from all paragraph text nodes. """ 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) # Module-level singletons — initialised once per Lambda cold start to avoid # redundant Secrets Manager calls on every invocation. _config: 'JiraConfig | None' = None _client: 'JiraClient | None' = None def _get_config() -> JiraConfig: """Return the module-level JiraConfig singleton, creating it if needed.""" global _config if _config is None: _config = JiraConfig() return _config def _get_client() -> JiraClient: """Return the module-level JiraClient singleton, creating it if needed.""" global _client if _client is None: _client = JiraClient(_get_config()) return _client def handler(event: dict[str, Any], context: Any) -> dict[str, Any]: """Route Lambda event to the appropriate action handler. :param event: Lambda event dict with required 'action' and 'dry_run' keys :param context: Lambda context object (unused) :return: Action-specific response dict :raises ValueError: If action is unknown """ action = event.get('action') if action == 'query-offboarding-tickets': return _handle_query_tickets( QueryOffboardingTicketsEvent.model_validate(event), OFFBOARDING, OFFBOARDING.jql_new, QueryOffboardingTicketsResponse, ) if action == 'query-approved-tickets': return _handle_query_tickets( QueryApprovedTicketsEvent.model_validate(event), OFFBOARDING, OFFBOARDING.jql_approved, QueryApprovedTicketsResponse, ) if action == 'query-suspend-tickets': return _handle_query_tickets( QuerySuspendTicketsEvent.model_validate(event), SUSPENSION, SUSPENSION.jql_new, QuerySuspendTicketsResponse, ) if action == 'query-approved-suspend-tickets': return _handle_query_tickets( QueryApprovedSuspendTicketsEvent.model_validate(event), SUSPENSION, SUSPENSION.jql_approved, QueryApprovedSuspendTicketsResponse, ) if action == 'query-completed-tickets': return _handle_query_completed_tickets( QueryCompletedTicketsEvent.model_validate(event) ) if action == 'validate-ticket': return _handle_validate_ticket(ValidateTicketEvent.model_validate(event)) if action == 'add-comment': return _handle_add_comment(AddCommentEvent.model_validate(event)) if action == 'add-due-date': return _handle_add_due_date(AddDueDateEvent.model_validate(event)) if action == 'add-label': return _handle_add_label(AddLabelEvent.model_validate(event)) if action == 'close-ticket': return _handle_close_ticket(CloseTicketEvent.model_validate(event)) raise ValueError(f'Unknown action: {action!r}') def _handle_query_tickets( evt: QueryOffboardingTicketsEvent | QueryApprovedTicketsEvent | QuerySuspendTicketsEvent | QueryApprovedSuspendTicketsEvent, ticket_type: TicketType, query: str, response_cls: type[QueryOffboardingTicketsResponse] | type[QueryApprovedTicketsResponse] | type[QuerySuspendTicketsResponse] | type[QueryApprovedSuspendTicketsResponse], ) -> dict[str, Any]: """Query Jira for *ticket_type* tickets matching *query* and parse fields. Detection and parsing are driven by *ticket_type*: its sentinel regex confirms the description flavour, and its full-name regex / date extractor pull the structured fields. Tickets missing description, email, full_name, or the effective date are soft-skipped (logged and excluded from output). Jira 401/403/5xx errors propagate as exceptions to cause a Step Functions task failure. :param evt: Validated event (no extra fields required) :param ticket_type: The ticket-type config driving detection and parsing :param query: JQL query string to execute :param response_cls: Pydantic response model class to instantiate :return: {"tickets": [{"id", "email", "full_name", "last_working_day"}]} """ client = _get_client() text_utils = TextUtils() search_utils = SearchUtils() raw_tickets = client.query_jira_tickets(query) tickets: list[Ticket] = [] for ticket in raw_tickets: ticket_id = ticket.get('key', 'UNKNOWN') fields = ticket.get('fields', {}) description = fields.get('description') if description is None: LOGGER.warning('Ticket %s has no description field — skipping.', ticket_id) continue content_blocks = description.get('content') if content_blocks is None: LOGGER.warning( 'Ticket %s has no description content — skipping.', ticket_id ) continue combined_text = _flatten_adf_content(content_blocks) if not ticket_type.sentinel_re.search(combined_text): LOGGER.warning( 'Ticket %s: description does not contain %s request text — skipping.', ticket_id, ticket_type.name, ) continue clean = text_utils.clean_text(combined_text) emails = search_utils.find_all_emails(clean) if not emails: LOGGER.warning( 'Ticket %s: no email found in description — skipping.', ticket_id, ) continue email = emails[0] full_name = search_utils.find_full_name(clean, ticket_type.full_name_re) if not full_name: LOGGER.warning( 'Ticket %s: no full name found in description — skipping.', ticket_id, ) continue full_name = ' '.join(word.capitalize() for word in full_name.split()) last_working_day = ticket_type.date_extractor(clean) if not last_working_day: LOGGER.warning( 'Ticket %s: no effective date found in description — skipping.', ticket_id, ) continue LOGGER.info( 'Parsed ticket %s: email=%s full_name=%s last_working_day=%s', ticket_id, email, full_name, last_working_day, ) tickets.append( Ticket( id=ticket_id, email=email, full_name=full_name, last_working_day=last_working_day, ) ) return response_cls(tickets=tickets).model_dump() def _handle_query_completed_tickets( evt: QueryCompletedTicketsEvent, ) -> dict[str, Any]: """Return SYS tickets labelled complete but not yet Closed — key only. Used by the close state machine to find tickets whose offboarding/suspend automation finished (``automation-complete`` / ``suspension-complete``) so they can be transitioned to Closed on a later iteration. Deliberately does NOT parse ticket descriptions: closing needs only the issue key, and the description-parsing path (``_handle_query_tickets``) soft-skips tickets whose description no longer parses — which would wrongly exclude closeable tickets and leave them open forever. Jira 401/403/5xx errors propagate as exceptions to fail the Step Functions task. :param evt: Validated event (no extra fields required). :return: {"tickets": [{"id"}]} """ client = _get_client() raw_tickets = client.query_jira_tickets(JQLQueries.COMPLETED_TICKETS) tickets = [ CompletedTicket(id=ticket['key']) for ticket in raw_tickets if ticket.get('key') ] LOGGER.info('Found %d completed ticket(s) eligible for closing.', len(tickets)) return QueryCompletedTicketsResponse(tickets=tickets).model_dump() def _handle_validate_ticket(evt: ValidateTicketEvent) -> dict[str, Any]: """Validate that an offboarding ticket originated from the expected source. Checks the reporter email against the allowlist and verifies the description footer contains the expected helpdesk sentinel text. Returns ``valid=False`` and a non-empty ``warnings`` list when issues are found. Validation is intentionally soft: the Step Function decides how to handle warnings (e.g. post a comment and continue, or stop). No Jira writes are performed here. :param evt: Validated event with ticket_id. :return: {"ticket_id", "valid", "warnings"} """ client = _get_client() warnings: list[str] = [] fields = client.get_ticket_fields(evt.ticket_id, 'reporter,description') 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)}.' ) LOGGER.warning( 'Ticket %s: reporter %r not in allowlist.', evt.ticket_id, reporter_email ) description = fields.get('description') or {} content_blocks = description.get('content') or [] combined_text = _flatten_adf_content(content_blocks) 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.' ) LOGGER.warning( 'Ticket %s: footer sentinel not found in description.', evt.ticket_id ) 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. ' f'The ticket may not have been created via the expected helpdesk email.' ) LOGGER.warning( 'Ticket %s: footer email %r not found in description.', evt.ticket_id, TicketValidation.FOOTER_EMAIL, ) return ValidateTicketResponse( ticket_id=evt.ticket_id, valid=len(warnings) == 0, warnings=warnings, ).model_dump() def _handle_add_comment(evt: AddCommentEvent) -> dict[str, Any]: """Post a plain-text comment on a Jira ticket. :param evt: Validated event with ticket_id, comment, and dry_run flag. :return: {"ticket_id", "dry_run", "commented"} """ if evt.dry_run: LOGGER.info('[Dry run] Would add comment to ticket %s.', evt.ticket_id) return AddCommentResponse( ticket_id=evt.ticket_id, dry_run=True, commented=False ).model_dump() client = _get_client() client.add_comment(evt.ticket_id, evt.comment) LOGGER.info('Added comment to ticket %s.', evt.ticket_id) return AddCommentResponse( ticket_id=evt.ticket_id, dry_run=False, commented=True ).model_dump() def _handle_add_due_date(evt: AddDueDateEvent) -> dict[str, Any]: """Set the due date field on a Jira ticket. :param evt: Validated event with ticket_id, due_date, and dry_run flag. :return: {"ticket_id", "dry_run", "due_date_set"} """ if evt.dry_run: LOGGER.info( '[Dry run] Would set due date %s on ticket %s.', evt.due_date, evt.ticket_id, ) return AddDueDateResponse( ticket_id=evt.ticket_id, dry_run=True, due_date_set=False ).model_dump() client = _get_client() client.add_due_date(evt.ticket_id, evt.due_date) LOGGER.info('Set due date %s on ticket %s.', evt.due_date, evt.ticket_id) return AddDueDateResponse( ticket_id=evt.ticket_id, dry_run=False, due_date_set=True ).model_dump() def _handle_add_label(evt: AddLabelEvent) -> dict[str, Any]: """Append a label to a Jira ticket. :param evt: Validated event with ticket_id, label, and dry_run flag. :return: {"ticket_id", "dry_run", "label_added"} """ if evt.dry_run: LOGGER.info( '[Dry run] Would add label %r to ticket %s.', evt.label, evt.ticket_id ) return AddLabelResponse( ticket_id=evt.ticket_id, dry_run=True, label_added=False ).model_dump() client = _get_client() client.add_label(evt.ticket_id, evt.label) LOGGER.info('Added label %r to ticket %s.', evt.label, evt.ticket_id) return AddLabelResponse( ticket_id=evt.ticket_id, dry_run=False, label_added=True ).model_dump() def _handle_close_ticket(evt: CloseTicketEvent) -> dict[str, Any]: """Transition a Jira ticket to its closed/done status. :param evt: Validated event with ticket_id, target_status, and dry_run flag. :return: {"ticket_id", "dry_run", "closed"} """ if evt.dry_run: LOGGER.info( '[Dry run] Would close ticket %s (target status %r).', evt.ticket_id, evt.target_status, ) return CloseTicketResponse( ticket_id=evt.ticket_id, dry_run=True, closed=False ).model_dump() client = _get_client() client.close_ticket(evt.ticket_id, evt.target_status) LOGGER.info( 'Closed ticket %s (target status %r).', evt.ticket_id, evt.target_status ) return CloseTicketResponse( ticket_id=evt.ticket_id, dry_run=False, closed=True ).model_dump()