jbi/steps.py (335 lines of code) (raw):

""" Collection of reusable action steps. Each step takes an `ActionContext` and a list of arbitrary parameters. """ # This import is needed (as of Python 3.11) to enable type checking with modules # imported under `TYPE_CHECKING` # https://docs.python.org/3/whatsnew/3.7.html#pep-563-postponed-evaluation-of-annotations # https://docs.python.org/3/whatsnew/3.11.html#pep-563-may-not-be-the-future from __future__ import annotations import logging from enum import Enum, auto from typing import TYPE_CHECKING, Iterable, Optional from requests import exceptions as requests_exceptions from jbi import Operation from jbi.environment import get_settings class StepStatus(Enum): """ Options for the result of executing a step function: SUCCESS: The step succeeded at doing meaningful work INCOMPLETE: The step did not execute successfully, but it's an error we anticipated NOOP: The step executed successfully, but didn't have any meaningful work to do """ SUCCESS = auto() INCOMPLETE = auto() NOOP = auto() # https://docs.python.org/3.11/library/typing.html#typing.TYPE_CHECKING if TYPE_CHECKING: from jbi.bugzilla.service import BugzillaService from jbi.jira import JiraService from jbi.models import ActionContext, ActionParams StepResult = tuple[StepStatus, ActionContext] logger = logging.getLogger(__name__) def create_comment(context: ActionContext, *, jira_service: JiraService) -> StepResult: """Create a Jira comment using `context.bug.comment`""" bug = context.bug if context.event.target == "comment": if bug.comment is None: logger.info( "No matching comment found in payload", extra=context.model_dump(), ) return (StepStatus.NOOP, context) if not bug.comment.body: logger.info( "Comment message is empty", extra=context.model_dump(), ) return (StepStatus.NOOP, context) jira_response = jira_service.add_jira_comment(context) context = context.append_responses(jira_response) return (StepStatus.SUCCESS, context) def create_issue( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService, bugzilla_service: BugzillaService, ) -> StepResult: """Create the Jira issue with the first comment as the description.""" bug = context.bug issue_type = parameters.issue_type_map.get(bug.type or "", "Task") # In the payload of a bug creation, the `comment` field is `null`. description = bugzilla_service.get_description(bug.id) jira_create_response = jira_service.create_jira_issue( context, description, issue_type ) issue_key = jira_create_response.get("key") context = context.update( jira=context.jira.update(issue=issue_key), ) context = context.append_responses(jira_create_response) return (StepStatus.SUCCESS, context) def add_link_to_jira( context: ActionContext, *, bugzilla_service: BugzillaService ) -> StepResult: """Add the URL to the Jira issue in the `see_also` field on the Bugzilla ticket""" settings = get_settings() jira_url = f"{settings.jira_base_url}browse/{context.jira.issue}" logger.info( "Link %r on Bug %s", jira_url, context.bug.id, extra=context.update(operation=Operation.LINK).model_dump(), ) bugzilla_response = bugzilla_service.add_link_to_see_also(context.bug, jira_url) context = context.append_responses(bugzilla_response) return (StepStatus.SUCCESS, context) def add_link_to_bugzilla( context: ActionContext, *, jira_service: JiraService ) -> StepResult: """Add the URL of the Bugzilla ticket to the links of the Jira issue""" jira_response = jira_service.add_link_to_bugzilla(context) context = context.append_responses(jira_response) return (StepStatus.SUCCESS, context) def maybe_delete_duplicate( context: ActionContext, *, bugzilla_service: BugzillaService, jira_service: JiraService, ) -> StepResult: """ In the time taken to create the Jira issue the bug may have been updated so re-retrieve it to ensure we have the latest data, and delete any duplicate if two Jira issues were created for the same Bugzilla ticket. """ latest_bug = bugzilla_service.refresh_bug_data(context.bug) jira_response_delete = jira_service.delete_jira_issue_if_duplicate( context, latest_bug ) if jira_response_delete: context = context.append_responses(jira_response_delete) return (StepStatus.SUCCESS, context) return (StepStatus.NOOP, context) def update_issue_summary( context: ActionContext, *, jira_service: JiraService ) -> StepResult: """Update the Jira issue's summary if the linked bug is modified.""" if "summary" not in context.event.changed_fields(): return (StepStatus.NOOP, context) jira_response_update = jira_service.update_issue_summary(context) context = context.append_responses(jira_response_update) return (StepStatus.SUCCESS, context) def add_jira_comments_for_changes( context: ActionContext, *, jira_service: JiraService ) -> StepResult: """Add a Jira comment for each field (assignee, status, resolution) change on the Bugzilla ticket.""" comments_responses = jira_service.add_jira_comments_for_changes(context) context.append_responses(comments_responses) return (StepStatus.SUCCESS, context) def maybe_assign_jira_user( context: ActionContext, *, jira_service: JiraService ) -> StepResult: """Assign the user on the Jira issue, based on the Bugzilla assignee email. It will attempt to assign the Jira issue the same person as the bug is assigned to. This relies on the user using the same email address in both Bugzilla and Jira. If the user does not exist in Jira then the assignee is cleared from the Jira issue. The Jira account that JBI uses requires the "Browse users and groups" global permission in order to set the assignee. """ event = context.event bug = context.bug if context.operation == Operation.CREATE: if not bug.is_assigned(): return (StepStatus.NOOP, context) try: resp = jira_service.assign_jira_user(context, bug.assigned_to) # type: ignore context.append_responses(resp) return (StepStatus.SUCCESS, context) except ValueError as exc: logger.info(str(exc), extra=context.model_dump()) return (StepStatus.INCOMPLETE, context) if context.operation == Operation.UPDATE: if "assigned_to" not in event.changed_fields(): return (StepStatus.SUCCESS, context) if not bug.is_assigned(): resp = jira_service.clear_assignee(context) else: try: resp = jira_service.assign_jira_user(context, bug.assigned_to) # type: ignore except ValueError as exc: logger.info(str(exc), extra=context.model_dump()) # If that failed then just fall back to clearing the assignee. resp = jira_service.clear_assignee(context) context.append_responses(resp) return (StepStatus.SUCCESS, context) return (StepStatus.NOOP, context) def _maybe_update_issue_mapped_field( source_field: str, context: ActionContext, parameters: ActionParams, jira_service: JiraService, wrap_value: Optional[str] = None, ) -> StepResult: source_value = getattr(context.bug, source_field, None) or "" target_field = getattr(parameters, f"jira_{source_field}_field") target_value = getattr(parameters, f"{source_field}_map").get(source_value) # If field is empty on create, or update is about another field, then nothing to do. if (context.operation == Operation.CREATE and source_value in ["", "---"]) or ( context.operation == Operation.UPDATE and source_field not in context.event.changed_fields() ): return (StepStatus.NOOP, context) if target_value is None: logger.info( f"Bug {source_field} %r was not in the {source_field} map.", source_value, extra=context.update( operation=Operation.IGNORE, ).model_dump(), ) return (StepStatus.INCOMPLETE, context) resp = jira_service.update_issue_field( context, target_field, target_value, wrap_value, ) context.append_responses(resp) return (StepStatus.SUCCESS, context) def maybe_update_issue_priority( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Update the Jira issue priority """ return _maybe_update_issue_mapped_field( "priority", context, parameters, jira_service, wrap_value="name" ) def maybe_update_issue_resolution( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Update the Jira issue status https://support.atlassian.com/jira-cloud-administration/docs/what-are-issue-statuses-priorities-and-resolutions/ """ return _maybe_update_issue_mapped_field( "resolution", context, parameters, jira_service, wrap_value="name" ) def maybe_update_issue_severity( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Update the Jira issue severity """ return _maybe_update_issue_mapped_field( "severity", context, parameters, jira_service, wrap_value="value" ) def maybe_update_issue_points( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Update the Jira issue story points """ return _maybe_update_issue_mapped_field( "cf_fx_points", context, parameters, jira_service ) def maybe_update_issue_status( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Update the Jira issue resolution https://support.atlassian.com/jira-cloud-administration/docs/what-are-issue-statuses-priorities-and-resolutions/ """ bz_status = context.bug.resolution or context.bug.status jira_status = parameters.status_map.get(bz_status or "") if jira_status is None: logger.info( "Bug status %r was not in the status map.", bz_status, extra=context.update( operation=Operation.IGNORE, ).model_dump(), ) return (StepStatus.INCOMPLETE, context) if context.operation == Operation.CREATE: resp = jira_service.update_issue_status(context, jira_status) context.append_responses(resp) return (StepStatus.SUCCESS, context) if context.operation == Operation.UPDATE: changed_fields = context.event.changed_fields() if "status" in changed_fields or "resolution" in changed_fields: resp = jira_service.update_issue_status(context, jira_status) context.append_responses(resp) return (StepStatus.SUCCESS, context) return (StepStatus.NOOP, context) def maybe_update_components( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Update the Jira issue components """ candidate_components = set(parameters.jira_components.set_custom_components) if context.bug.component and parameters.jira_components.use_bug_component: candidate_components.add(context.bug.component) if context.bug.product and parameters.jira_components.use_bug_product: candidate_components.add(context.bug.product) if ( context.bug.product_component and parameters.jira_components.use_bug_component_with_product_prefix ): candidate_components.add(context.bug.product_component) if not candidate_components: # no components to update return (StepStatus.NOOP, context) # Although we previously introspected the project components, we # still have to catch any potential 400 error response here, because # the `components` field may not be on the create / update issue. if not context.jira.issue: raise ValueError("Jira issue unset in Action Context") try: resp, missing_components = jira_service.update_issue_components( context=context, components=candidate_components, ) except requests_exceptions.HTTPError as exc: if getattr(exc.response, "status_code", None) != 400: raise # If `components` is not a valid field on create/update screens, # then warn developers and ignore the error. logger.error( f"Could not set components on issue {context.jira.issue}: %s", str(exc), extra=context.model_dump(), ) context.append_responses(exc.response) return (StepStatus.INCOMPLETE, context) if missing_components: logger.warning( "Could not find components '%s' in project", ",".join(sorted(missing_components)), extra=context.model_dump(), ) return (StepStatus.INCOMPLETE, context) context.append_responses(resp) return (StepStatus.SUCCESS, context) def _whiteboard_as_labels(labels_brackets: str, whiteboard: Optional[str]) -> list[str]: """Split the whiteboard string into a list of labels""" splitted = whiteboard.replace("[", "").split("]") if whiteboard else [] stripped = [x.strip() for x in splitted if x not in ["", " "]] # Jira labels can't contain a " ", convert to "." nospace = [wb.replace(" ", ".") for wb in stripped] with_brackets = [f"[{wb}]" for wb in nospace] if labels_brackets == "yes": labels = with_brackets elif labels_brackets == "both": labels = nospace + with_brackets else: labels = nospace return ["bugzilla"] + labels def _build_labels_update( labels_brackets, added, removed=None ) -> tuple[list[str], list[str]]: # We don't bother detecting if label was already there. additions = _whiteboard_as_labels(labels_brackets, added) removals = [] if removed: before = _whiteboard_as_labels(labels_brackets, removed) removals = sorted( set(before).difference(set(additions)) ) # sorted for unit testing return additions, removals def sync_whiteboard_labels( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Set whiteboard tags as labels on the Jira issue. """ # On update of whiteboard field, add/remove corresponding labels if context.event.changes: changes_by_field = {change.field: change for change in context.event.changes} if change := changes_by_field.get("whiteboard"): additions, removals = _build_labels_update( added=change.added, removed=change.removed, labels_brackets=parameters.labels_brackets, ) else: return (StepStatus.NOOP, context) else: # On creation, just add them all. additions, removals = _build_labels_update( added=context.bug.whiteboard, labels_brackets=parameters.labels_brackets ) return _update_issue_labels(context, jira_service, additions, removals) def sync_keywords_labels( context: ActionContext, *, parameters: ActionParams, jira_service: JiraService ) -> StepResult: """ Set keywords as labels on the Jira issue. """ if context.event.changes: changes_by_field = {change.field: change for change in context.event.changes} if change := changes_by_field.get("keywords"): additions = [x.strip() for x in change.added.split(",")] removed = [x.strip() for x in change.removed.split(",")] removals = sorted( set(removed).difference(set(additions)) ) # sorted for unit testing else: return (StepStatus.NOOP, context) else: # On creation, just add them all. additions = context.bug.keywords or [] removals = [] return _update_issue_labels(context, jira_service, additions, removals) def _update_issue_labels( context: ActionContext, jira_service: JiraService, additions: Iterable[str], removals: Iterable[str], ) -> StepResult: if not context.jira.issue: raise ValueError("Jira issue unset in Action Context") try: resp = jira_service.update_issue_labels( issue_key=context.jira.issue, add=additions, remove=removals ) except requests_exceptions.HTTPError as exc: if getattr(exc.response, "status_code", None) != 400: raise # If `labels` is not a valid field in this project, then warn developers # and ignore the error. logger.error( f"Could not set labels on issue {context.jira.issue}: %s", str(exc), extra=context.model_dump(), ) context.append_responses(exc.response) return (StepStatus.INCOMPLETE, context) context.append_responses(resp) return (StepStatus.SUCCESS, context)