Source code for sg_jira.bridge

# Copyright 2018 Autodesk, Inc.  All rights reserved.
#
# Use of this software is subject to the terms of the Autodesk license agreement
# provided at the time of installation or download, or which otherwise accompanies
# this software in either electronic or hard copy form.
#

import os
import imp
import logging
import logging.config
import importlib
from six.moves import urllib
import threading

from .shotgun_session import ShotgunSession
from .jira_session import JiraSession
from .constants import ALL_SETTINGS_KEYS
from .constants import LOGGING_SETTINGS_KEY, SYNC_SETTINGS_KEY
from .constants import SHOTGUN_SETTINGS_KEY, JIRA_SETTINGS_KEY
from .utils import utf8_to_unicode

logger = logging.getLogger(__name__)
# Ensure basic logging is always enabled
logging.basicConfig(format="%(levelname)s:%(name)s:%(message)s")


[docs]class Bridge(object): """ A bridge between Flow Production Tracking and Jira. The bridge handles connections to the Flow Production Tracking and Jira servers and dispatches sync events. """
[docs] def __init__( self, sg_site, sg_script, sg_script_key, jira_site, jira_user, jira_secret, sync_settings=None, sg_http_proxy=None, ): """ Instatiate a new bridge between the given PTR site and Jira site. .. note:: Jira Cloud requires the use of an API token and will not work with a user's password. See https://confluence.atlassian.com/x/Vo71Nw for information on how to generate a token. Jira Server will use PAT so please provide empty string as `SGJIRA_JIRA_USER`. :param str sg_site: A Flow Production Tracking URL. :param str sg_script: A Flow Production Tracking script user name. :param str sg_script_key: The script user key for the Flow Production Tracking script. :param str jira_site: A Jira site url. :param str jira_user: A Jira user name, either his email address or short name. :param str jira_secret: The Jira user password or API key. :param sync_settings: A dictionary where keys are settings names. :param str sg_http_proxy: Optional, a http proxy to use for the Flow Production Tracking connection, or None. """ super(Bridge, self).__init__() # The bridge webserver is multithreaded, which means we need to # track Shotgun connections via the API per thread. The PTR Python # API is not threadsafe, and using a single, global connection # across all threads will lead to some weird behavior. self._SG_CACHED_CONNECTIONS = threading.local() self._sg_site = sg_site self._sg_script = sg_script self._sg_script_key = sg_script_key self._sg_http_proxy = sg_http_proxy # Even though we will end up needing a connection per thread, we # still need to do a one-time check to make sure the site we're # connecting to is setup properly for use with the bridge. That # logic is run via the setup() method on the session object, so # we will connect here and call that a single time since there's # no reason to do that validation pass from each thread when we # create new connections. shotgun = ShotgunSession( sg_site, script_name=sg_script, api_key=sg_script_key, http_proxy=sg_http_proxy, ) shotgun.add_user_agent("sg_jira_sync") shotgun.setup() self._jira_user = jira_user options = ( {"token_auth": jira_secret} if jira_user in (None, "None", "") else {"basic_auth": (jira_user, jira_secret)} ) self._jira = JiraSession(jira_site, **options) self._sync_settings = sync_settings or {} self._syncers = {} self._jira.setup()
[docs] @classmethod def get_bridge(cls, settings_file): """ Read the given settings and instantiate a new :class:`Bridge` with them. :param str settings_file: Path to a settings Python file. :raises ValueError: on missing required settings. """ # make sure we have an absolute path settings_file_path = os.path.abspath(settings_file) # Read settings ( logger_settings, shotgun_settings, jira_settings, sync_settings, ) = cls.read_settings(settings_file_path) if logger_settings: logging.config.dictConfig(logger_settings) logger.info("Successfully read settings from %s" % settings_file_path) try: return cls( shotgun_settings["site"], shotgun_settings["script_name"], shotgun_settings["script_key"], jira_settings["site"], jira_settings["user"], jira_settings["secret"], sync_settings, sg_http_proxy=shotgun_settings.get("http_proxy"), ) except Exception as e: logger.exception(e) raise
[docs] @classmethod def read_settings(cls, settings_file): """ Read the given settings file. :param str settings_file: Path to a settings Python file. :returns: A tuple of settings: (logger settings, shotgun settings, jira settings, sync settings) :raises ValueError: if the file does not exist or if its name does not end with ``.py``. """ full_path = os.path.abspath(settings_file) if not os.path.exists(settings_file): raise ValueError("Settings file %s does not exist" % full_path) if not full_path.endswith(".py"): raise ValueError( "Settings file %s is not a Python file with a .py extension" % full_path ) folder, module_name = os.path.split(full_path) mfile, pathname, description = imp.find_module( # Strip the .py extension os.path.splitext(module_name)[0], [folder], ) try: module = imp.load_module( "%s.settings" % __name__, mfile, pathname, description ) finally: if mfile: mfile.close() # Retrieve all properties we handle and provide empty values if missing settings = dict( [ (prop_name, getattr(module, prop_name, None)) for prop_name in ALL_SETTINGS_KEYS ] ) # Set logging from settings logger_settings = settings[LOGGING_SETTINGS_KEY] # Retrieve Shotgun connection settings shotgun_settings = settings[SHOTGUN_SETTINGS_KEY] if not shotgun_settings: raise ValueError("Missing Shotgun settings in %s" % full_path) missing = [ name for name in ["site", "script_name", "script_key"] if not shotgun_settings.get(name) ] if missing: raise ValueError( "Missing Shotgun setting values %s in %s" % (missing, full_path) ) # Retrieve Jira connection settings jira_settings = settings[JIRA_SETTINGS_KEY] if not jira_settings: raise ValueError("Missing Jira settings in %s" % full_path) missing = [name for name in ["site", "secret"] if not jira_settings.get(name)] if missing: raise ValueError( "Missing Jira setting values %s in %s" % (missing, full_path) ) sync_settings = settings[SYNC_SETTINGS_KEY] if not sync_settings: raise ValueError("Missing sync settings in %s" % full_path) return logger_settings, shotgun_settings, jira_settings, sync_settings
@property def shotgun(self): """ Return a connected :class:`~shotgun_session.ShotgunSession` instance. """ # This ensures we end up with a connection per thread. See the comment # at the top of this file where the global cache is initialized for a # full explanation. sg = getattr(self._SG_CACHED_CONNECTIONS, "sg", None) if sg is None: sg = ShotgunSession( self._sg_site, script_name=self._sg_script, api_key=self._sg_script_key, http_proxy=self._sg_http_proxy, ) sg.add_user_agent("sg_jira_sync") self._SG_CACHED_CONNECTIONS.sg = sg return sg @property def current_shotgun_user(self): """ Return the Flow Production Tracking user used for the connection. :returns: A Flow Production Tracking record dictionary with an `id` key and a `type` key. """ return self.shotgun.current_user @property def current_jira_username(self): """ Return the username of the current Jira user. The jira API escapes special characters using %xx syntax when storing the username. For example, the username ``richard+hendricks`` is stored as ``richard%2bhendricks`` by the jira API. We decode the username here before returning it to ensure we return the exact value (eg. ``richard+hendricks``) :returns: A string with the username. """ return urllib.parse.unquote_plus(self.jira.current_user() or self._jira_user) @property def jira(self): """ Return a connected :class:`~jira_session.JiraSession` instance. """ return self._jira @property def sync_settings_names(self): """ Return the list of sync settings this bridge handles. """ return list(self._sync_settings.keys())
[docs] def reset(self): """ Reset the bridge. Clears all caches. """ logger.debug("Resetting bridge") self.shotgun.clear_cached_field_schema()
[docs] def get_syncer(self, name): """ Returns a :class:`Syncer` instance for the given settings name. :param str: A settings name. :raises ValueError: for invalid settings. """ if name not in self._syncers: # Create the syncer from the settings sync_settings = self._sync_settings.get(name) if sync_settings is None: raise ValueError("Missing sync settings for %s" % name) if not isinstance(sync_settings, dict): raise ValueError( "Invalid sync settings for %s, it must be dictionary." % name ) # Retrieve the syncer syncer_name = sync_settings.get("syncer") if not syncer_name: raise ValueError("Missing `syncer` setting for %s" % name) if "." not in syncer_name: raise ValueError( "Invalid `syncer` setting %s for %s: " "it must be a <module path>.<class name>" % (syncer_name, name) ) module_name, class_name = syncer_name.rsplit(".", 1) module = importlib.import_module(module_name) try: syncer_class = getattr(module, class_name) except AttributeError as e: logger.debug("%s" % e, exc_info=True) raise ValueError( "Unable to retrieve a %s class from module %s" % ( class_name, module, ) ) # Retrieve the settings for the syncer, if any settings = sync_settings.get("settings") or {} # Instantiate the syncer with our standard parameters and any # additional settings as parameters. self._syncers[name] = syncer_class(name=name, bridge=self, **settings) self._syncers[name].setup() return self._syncers[name]
[docs] def sync_in_jira(self, settings_name, entity_type, entity_id, event, **kwargs): """ Sync the given Flow Production Tracking Entity to Jira. :param str settings_name: The name of the settings to use for this sync. :param str entity_type: The Flow Production Tracking Entity type to sync. :param int entity_id: The id of the Flow Production Tracking Entity to sync. :param event: A dictionary with the event meta data for the change. :returns: True if the Entity was actually synced in Jira, False if syncing was skipped for any reason. """ synced = False try: # Shotgun events might contain utf-8 encoded strings, convert them # to unicode before processing. safe_event = utf8_to_unicode(event) syncer = self.get_syncer(settings_name) # See comment in Syncer class: we assume complicated logic can be # handled in a single handler, so we don't have to support multiple # handlers. handler = syncer.accept_shotgun_event(entity_type, entity_id, safe_event) if handler: self.shotgun.set_session_uuid(safe_event.get("session_uuid")) synced = handler.process_shotgun_event( entity_type, entity_id, safe_event ) except Exception as e: # Catch the exception to log it and let it bubble up logger.exception(e) raise return synced
[docs] def sync_in_shotgun( self, settings_name, resource_type, resource_id, event, **kwargs ): """ Sync the given Jira Resource to Flow Production Tracking. :param str settings_name: The name of the settings to use for this sync. :param str resource_type: The type of Jira resource sync, e.g. Issue. :param str resource_id: The id of the Jira resource to sync. :param event: A dictionary with the event meta data for the change. :returns: True if the resource was actually synced in Flow Production Tracking, False if syncing was skipped for any reason. """ synced = False try: syncer = self.get_syncer(settings_name) # See comment in Syncer class: we assume copmlicated logic can be # handled in a single handler, so we don't have to support multiple # handlers. handler = syncer.accept_jira_event(resource_type, resource_id, event) if handler: synced = handler.process_jira_event(resource_type, resource_id, event) except Exception as e: # Catch the exception to log it and let it bubble up logger.exception(e) raise return synced