Added storyboard integration to tempest.lib decorators

A new parameter is introduced to related_bug/skip_because
decorators called `bug_type` which takes in either 'launchpad'
(default for backward compatibility) or 'storyboard'. The
appropriate link for each tracking page is generated based off
this value and the 'bug' value.

This is useful for projects like Monasca Tempest plugin which
tracks issues on Storyboard. Also,  Storyboard is the new community
preferred place to track stories and bugs [0]. So Tempest
should provide projects with the ability to track bugs using it.

Note that Storyboard does **not** only track stories. It tracks
bugs too: "It all begins with a story. A story is a bug report or
proposed feature. Stories are then further split into tasks,
which affect a given project and branch."

Unit tests and releasenotes are included.

[0] https://wiki.openstack.org/wiki/StoryBoard
[1] https://storyboard.openstack.org/#!/page/about

Co-Authored-By: Felipe Monteiro <felipe.monteiro@att.com>
Change-Id: Ic34208cfe997ceacdafd1ce122691b58a9778e78
diff --git a/releasenotes/notes/add-storyboard-in-skip-because-decorator-3e139aa8a4f7970f.yaml b/releasenotes/notes/add-storyboard-in-skip-because-decorator-3e139aa8a4f7970f.yaml
new file mode 100644
index 0000000..dd4a90b
--- /dev/null
+++ b/releasenotes/notes/add-storyboard-in-skip-because-decorator-3e139aa8a4f7970f.yaml
@@ -0,0 +1,17 @@
+---
+features:
+  - |
+    Add a new parameter called ``bug_type`` to
+    ``tempest.lib.decorators.related_bug`` and
+    ``tempest.lib.decorators.skip_because`` decorators, which accepts
+    2 values:
+
+    * launchpad
+    * storyboard
+
+    This offers the possibility of tracking bugs related to tests using
+    launchpad or storyboard references. The default value is launchpad
+    for backward compatibility.
+
+    Passing in a non-digit ``bug`` value to either decorator will raise
+    a ``InvalidParam`` exception (previously ``ValueError``).
diff --git a/tempest/lib/decorators.py b/tempest/lib/decorators.py
index e99dd24..b399aa0 100644
--- a/tempest/lib/decorators.py
+++ b/tempest/lib/decorators.py
@@ -19,39 +19,83 @@
 import six
 import testtools
 
+from tempest.lib import exceptions as lib_exc
+
 LOG = logging.getLogger(__name__)
 
+_SUPPORTED_BUG_TYPES = {
+    'launchpad': 'https://launchpad.net/bugs/%s',
+    'storyboard': 'https://storyboard.openstack.org/#!/story/%s',
+}
+
+
+def _validate_bug_and_bug_type(bug, bug_type):
+    """Validates ``bug`` and ``bug_type`` values.
+
+    :param bug: bug number causing the test to skip (launchpad or storyboard)
+    :param bug_type: 'launchpad' or 'storyboard', default 'launchpad'
+    :raises: InvalidParam if ``bug`` is not a digit or ``bug_type`` is not
+        a valid value
+    """
+    if not bug.isdigit():
+        invalid_param = '%s must be a valid %s number' % (bug, bug_type)
+        raise lib_exc.InvalidParam(invalid_param=invalid_param)
+    if bug_type not in _SUPPORTED_BUG_TYPES:
+        invalid_param = 'bug_type "%s" must be one of: %s' % (
+            bug_type, ', '.join(_SUPPORTED_BUG_TYPES.keys()))
+        raise lib_exc.InvalidParam(invalid_param=invalid_param)
+
+
+def _get_bug_url(bug, bug_type='launchpad'):
+    """Get the bug URL based on the ``bug_type`` and ``bug``
+
+    :param bug: The launchpad/storyboard bug number causing the test
+    :param bug_type: 'launchpad' or 'storyboard', default 'launchpad'
+    :returns: Bug URL corresponding to ``bug_type`` value
+    """
+    _validate_bug_and_bug_type(bug, bug_type)
+    return _SUPPORTED_BUG_TYPES[bug_type] % bug
+
 
 def skip_because(*args, **kwargs):
     """A decorator useful to skip tests hitting known bugs
 
-    @param bug: bug number causing the test to skip
-    @param condition: optional condition to be True for the skip to have place
+    ``bug`` must be a number and ``condition`` must be true for the test to
+    skip.
+
+    :param bug: bug number causing the test to skip (launchpad or storyboard)
+    :param bug_type: 'launchpad' or 'storyboard', default 'launchpad'
+    :param condition: optional condition to be True for the skip to have place
+    :raises: testtools.TestCase.skipException if ``condition`` is True and
+        ``bug`` is included
     """
     def decorator(f):
         @functools.wraps(f)
         def wrapper(*func_args, **func_kwargs):
             skip = False
+            msg = ''
             if "condition" in kwargs:
                 if kwargs["condition"] is True:
                     skip = True
             else:
                 skip = True
             if "bug" in kwargs and skip is True:
-                if not kwargs['bug'].isdigit():
-                    raise ValueError('bug must be a valid bug number')
-                msg = "Skipped until Bug: %s is resolved." % kwargs["bug"]
+                bug = kwargs['bug']
+                bug_type = kwargs.get('bug_type', 'launchpad')
+                bug_url = _get_bug_url(bug, bug_type)
+                msg = "Skipped until bug: %s is resolved." % bug_url
                 raise testtools.TestCase.skipException(msg)
             return f(*func_args, **func_kwargs)
         return wrapper
     return decorator
 
 
-def related_bug(bug, status_code=None):
-    """A decorator useful to know solutions from launchpad bug reports
+def related_bug(bug, status_code=None, bug_type='launchpad'):
+    """A decorator useful to know solutions from launchpad/storyboard reports
 
-    @param bug: The launchpad bug number causing the test
-    @param status_code: The status code related to the bug report
+    :param bug: The launchpad/storyboard bug number causing the test bug
+    :param bug_type: 'launchpad' or 'storyboard', default 'launchpad'
+    :param status_code: The status code related to the bug report
     """
     def decorator(f):
         @functools.wraps(f)
@@ -61,9 +105,10 @@
             except Exception as exc:
                 exc_status_code = getattr(exc, 'status_code', None)
                 if status_code is None or status_code == exc_status_code:
-                    LOG.error('Hints: This test was made for the bug %s. '
-                              'The failure could be related to '
-                              'https://launchpad.net/bugs/%s', bug, bug)
+                    if bug:
+                        LOG.error('Hints: This test was made for the bug_type '
+                                  '%s. The failure could be related to '
+                                  '%s', bug, _get_bug_url(bug, bug_type))
                 raise exc
         return wrapper
     return decorator
diff --git a/tempest/tests/lib/test_decorators.py b/tempest/tests/lib/test_decorators.py
index ed0eea3..0b1a599 100644
--- a/tempest/tests/lib/test_decorators.py
+++ b/tempest/tests/lib/test_decorators.py
@@ -19,6 +19,7 @@
 from tempest.lib import base as test
 from tempest.lib.common.utils import data_utils
 from tempest.lib import decorators
+from tempest.lib import exceptions as lib_exc
 from tempest.tests import base
 
 
@@ -62,21 +63,40 @@
 
         t = TestFoo('test_bar')
         if expected_to_skip:
-            self.assertRaises(testtools.TestCase.skipException, t.test_bar)
+            e = self.assertRaises(testtools.TestCase.skipException, t.test_bar)
+            bug = decorator_args['bug']
+            bug_type = decorator_args.get('bug_type', 'launchpad')
+            self.assertRegex(
+                str(e),
+                r'Skipped until bug\: %s.*' % decorators._get_bug_url(
+                    bug, bug_type)
+            )
         else:
             # assert that test_bar returned 0
             self.assertEqual(TestFoo('test_bar').test_bar(), 0)
 
-    def test_skip_because_bug(self):
+    def test_skip_because_launchpad_bug(self):
         self._test_skip_because_helper(bug='12345')
 
-    def test_skip_because_bug_and_condition_true(self):
+    def test_skip_because_launchpad_bug_and_condition_true(self):
         self._test_skip_because_helper(bug='12348', condition=True)
 
-    def test_skip_because_bug_and_condition_false(self):
+    def test_skip_because_launchpad_bug_and_condition_false(self):
         self._test_skip_because_helper(expected_to_skip=False,
                                        bug='12349', condition=False)
 
+    def test_skip_because_storyboard_bug(self):
+        self._test_skip_because_helper(bug='1992', bug_type='storyboard')
+
+    def test_skip_because_storyboard_bug_and_condition_true(self):
+        self._test_skip_because_helper(bug='1992', bug_type='storyboard',
+                                       condition=True)
+
+    def test_skip_because_storyboard_bug_and_condition_false(self):
+        self._test_skip_because_helper(expected_to_skip=False,
+                                       bug='1992', bug_type='storyboard',
+                                       condition=False)
+
     def test_skip_because_bug_without_bug_never_skips(self):
         """Never skip without a bug parameter."""
         self._test_skip_because_helper(expected_to_skip=False,
@@ -84,8 +104,8 @@
         self._test_skip_because_helper(expected_to_skip=False)
 
     def test_skip_because_invalid_bug_number(self):
-        """Raise ValueError if with an invalid bug number"""
-        self.assertRaises(ValueError, self._test_skip_because_helper,
+        """Raise InvalidParam if with an invalid bug number"""
+        self.assertRaises(lib_exc.InvalidParam, self._test_skip_because_helper,
                           bug='critical_bug')
 
 
@@ -126,6 +146,13 @@
 
 
 class TestRelatedBugDecorator(base.TestCase):
+
+    def _get_my_exception(self):
+        class MyException(Exception):
+            def __init__(self, status_code):
+                self.status_code = status_code
+        return MyException
+
     def test_relatedbug_when_no_exception(self):
         f = mock.Mock()
         sentinel = object()
@@ -137,10 +164,9 @@
         test_foo(sentinel)
         f.assert_called_once_with(sentinel)
 
-    def test_relatedbug_when_exception(self):
-        class MyException(Exception):
-            def __init__(self, status_code):
-                self.status_code = status_code
+    def test_relatedbug_when_exception_with_launchpad_bug_type(self):
+        """Validate related_bug decorator with bug_type == 'launchpad'"""
+        MyException = self._get_my_exception()
 
         def f(self):
             raise MyException(status_code=500)
@@ -152,4 +178,53 @@
         with mock.patch.object(decorators.LOG, 'error') as m_error:
             self.assertRaises(MyException, test_foo, object())
 
-        m_error.assert_called_once_with(mock.ANY, '1234', '1234')
+        m_error.assert_called_once_with(
+            mock.ANY, '1234', 'https://launchpad.net/bugs/1234')
+
+    def test_relatedbug_when_exception_with_storyboard_bug_type(self):
+        """Validate related_bug decorator with bug_type == 'storyboard'"""
+        MyException = self._get_my_exception()
+
+        def f(self):
+            raise MyException(status_code=500)
+
+        @decorators.related_bug(bug="1234", status_code=500,
+                                bug_type='storyboard')
+        def test_foo(self):
+            f(self)
+
+        with mock.patch.object(decorators.LOG, 'error') as m_error:
+            self.assertRaises(MyException, test_foo, object())
+
+        m_error.assert_called_once_with(
+            mock.ANY, '1234', 'https://storyboard.openstack.org/#!/story/1234')
+
+    def test_relatedbug_when_exception_invalid_bug_type(self):
+        """Check related_bug decorator raises exc when bug_type is not valid"""
+        MyException = self._get_my_exception()
+
+        def f(self):
+            raise MyException(status_code=500)
+
+        @decorators.related_bug(bug="1234", status_code=500,
+                                bug_type=mock.sentinel.invalid)
+        def test_foo(self):
+            f(self)
+
+        with mock.patch.object(decorators.LOG, 'error'):
+            self.assertRaises(lib_exc.InvalidParam, test_foo, object())
+
+    def test_relatedbug_when_exception_invalid_bug_number(self):
+        """Check related_bug decorator raises exc when bug_number != digit"""
+        MyException = self._get_my_exception()
+
+        def f(self):
+            raise MyException(status_code=500)
+
+        @decorators.related_bug(bug="not a digit", status_code=500,
+                                bug_type='launchpad')
+        def test_foo(self):
+            f(self)
+
+        with mock.patch.object(decorators.LOG, 'error'):
+            self.assertRaises(lib_exc.InvalidParam, test_foo, object())