Skip to content

Commit

Permalink
Add the link command and linking new tests to jira (#2922)
Browse files Browse the repository at this point in the history
Co-authored-by: Martin Hoyer <[email protected]>
Co-authored-by: Petr Šplíchal <[email protected]>
Co-authored-by: Miloš Prchlík <[email protected]>
  • Loading branch information
4 people authored Sep 30, 2024
1 parent 1e0cee2 commit cddc98e
Show file tree
Hide file tree
Showing 9 changed files with 489 additions and 16 deletions.
2 changes: 2 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ repos:
- "requests>=2.25.1" # 2.28.2 / 2.31.0
- "ruamel.yaml>=0.16.6" # 0.17.32 / 0.17.32
- "urllib3>=1.26.5, <2.0" # 1.26.16 / 2.0.4
- "jira>=3.5.0, <4"

# report-junit
- "lxml>=4.6.5"
Expand Down Expand Up @@ -89,6 +90,7 @@ repos:
- "requests>=2.25.1" # 2.28.2 / 2.31.0
- "ruamel.yaml>=0.16.6" # 0.17.32 / 0.17.32
- "urllib3>=1.26.5, <2.0" # 1.26.16 / 2.0.4
- "jira>=3.5.0, <4"

# report-junit
- "lxml>=4.6.5"
Expand Down
41 changes: 41 additions & 0 deletions docs/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -773,6 +773,47 @@ locations without any change to the resulting `fmf` tree:
This gives you a nice flexibility to extend the metadata when and
where needed as your project organically grows.


.. _link-issues:

Link Issues
------------------------------------------------------------------

You can link issues to the test or plan that covers it. This can
be done either directly during creation of a new test or plan, or
later using the ``tmt link`` command:

.. code-block:: shell
tmt link verifies:https://issues.redhat.com/browse/YOUR-ISSUE tests/core/smoke
In order to enable this feature, create a configuration file
``.config/tmt/link.fmf`` and define an ``issue-tracker`` section
there. Once the configuration is present, it enables the linking
on its own, no further action is needed. The section should have
the following format:

.. code-block:: yaml
issue-tracker:
- type: jira
url: https://issues.redhat.com
tmt-web-url: https://tmt.testing-farm.io/
token: <YOUR_PERSONAL_JIRA_TOKEN>
The ``type`` key specifies the type of the issue tracking service
you want to link to (so far only Jira is supported). The ``url``
is the URL of said service. The ``tmt-web-url`` is the URL of the
service that presents tmt metadata in a human-readable form. The
``token`` is a personal token that is used to authenticate the
user. How to obtain this token is described `here
<https://support.atlassian.com/atlassian-account/docs/
manage-api-tokens-for-your-atlassian-account/#Create-an-API-token>`_
(please note that this can vary if you use custom Jira instance).

.. versionadded:: 1.37


.. _anchors-aliases:

Anchors and Aliases
Expand Down
5 changes: 5 additions & 0 deletions docs/releases.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
tmt-1.37.0
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

The new ``tmt link`` command has been included as a Tech Preview
to gather early feedback from users about the way how issues are
linked with newly created and existing tests and plans. See the
:ref:`link-issues` section for details about the configuration.

The :ref:`/plugins/report/polarion` report plugin now uses Jinja template to
generate the XUnit file. It doesn't do any extra modifications to the XML tree
using an ``ElementTree`` anymore. Also the schema is now validated against the
Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ report-polarion = [
"tmt[report-junit]",
"tmt[export-polarion]",
]
link-jira = [
"jira>=3.5.0, <4",
]
all = [
"tmt[test-convert]",
"tmt[export-polarion]",
Expand All @@ -76,6 +79,7 @@ all = [
"tmt[provision-beaker]",
"tmt[report-junit]",
"tmt[report-polarion]",
"tmt[link-jira]",
]
# Needed for readthedocs and man page build. Not being packaged in rpm.
docs = [
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import queue
import re
import shutil
import signal
import textwrap
import threading
Expand All @@ -20,6 +21,7 @@
import tmt.plugins
import tmt.steps.discover
import tmt.utils
import tmt.utils.jira
from tmt.log import Logger
from tmt.utils import (
Command,
Expand Down Expand Up @@ -1692,3 +1694,93 @@ def test_invocation_terminate_process_not_running_anymore(
caplog,
message=MATCH(r'Test invocation process cannot be terminated because it is unset.'),
levelno=logging.DEBUG)


class TestJiraLink(unittest.TestCase):
def setUp(self):
self.logger = tmt.log.Logger(actual_logger=logging.getLogger('tmt'))
self.tmp = Path(__file__).parent / Path("tmp")
self.tmp.mkdir()
fmf.Tree.init(path=self.tmp)
config_yaml = """
/link:
issue-tracker:
- type: jira
url: https://issues.redhat.com
tmt-web-url: https://tmt.testing-farm.io/
token: secret
""".strip()
self.config_tree = fmf.Tree(data=tmt.utils.yaml_to_dict(config_yaml))
tmt.base.Test.create(
names=['tmp/test'],
template='shell',
path=self.tmp,
logger=self.logger)
tmt.base.Plan.create(
names=['tmp/plan'],
template='mini',
path=self.tmp,
logger=self.logger)
tmt.base.Story.create(
names=['tmp/story'],
template='mini',
path=self.tmp,
logger=self.logger)

def tearDown(self):
# Cleanup the created files of tmt objects
shutil.rmtree(self.tmp)

@unittest.mock.patch('jira.JIRA.add_simple_link')
@unittest.mock.patch('tmt.utils.Config')
def test_jira_link_test_only(self, mock_config_tree, mock_add_simple_link) -> None:
mock_config_tree.return_value.fmf_tree = self.config_tree
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
tmt.utils.jira.link(
tmt_objects=[test],
links=tmt.base.Links(data=['verifies:https://issues.redhat.com/browse/TT-262']),
logger=self.logger)
result = mock_add_simple_link.call_args.args[1]
assert 'https://tmt.testing-farm.io/?' in result['url']
assert 'test-url=https%3A%2F%2Fgithub.com%2Fteemtee%2Ftmt' in result['url']
assert '&test-name=%2Ftmp%2Ftest' in result['url']
assert '&test-path=%2Ftests%2Funit%2Ftmp' in result['url']

@unittest.mock.patch('jira.JIRA.add_simple_link')
@unittest.mock.patch('tmt.utils.Config')
def test_jira_link_test_plan_story(self, mock_config_tree, mock_add_simple_link) -> None:
mock_config_tree.return_value.fmf_tree = self.config_tree
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
plan = tmt.Tree(logger=self.logger, path=self.tmp).plans(names=['tmp'])[0]
story = tmt.Tree(logger=self.logger, path=self.tmp).stories(names=['tmp'])[0]
tmt.utils.jira.link(
tmt_objects=[test, plan, story],
links=tmt.base.Links(data=['verifies:https://issues.redhat.com/browse/TT-262']),
logger=self.logger)
result = mock_add_simple_link.call_args.args[1]
assert 'https://tmt.testing-farm.io/?' in result['url']

assert 'test-url=https%3A%2F%2Fgithub.com%2Fteemtee%2Ftmt' in result['url']
assert '&test-name=%2Ftmp%2Ftest' in result['url']
assert '&test-path=%2Ftests%2Funit%2Ftmp' in result['url']

assert '&plan-url=https%3A%2F%2Fgithub.com%2Fteemtee%2Ftmt' in result['url']
assert '&plan-name=%2Ftmp%2Fplan' in result['url']
assert '&plan-path=%2Ftests%2Funit%2Ftmp' in result['url']

assert '&story-url=https%3A%2F%2Fgithub.com%2Fteemtee%2Ftmt' in result['url']
assert '&story-name=%2Ftmp%2Fstory' in result['url']
assert '&story-path=%2Ftests%2Funit%2Ftmp' in result['url']

@unittest.mock.patch('jira.JIRA.add_simple_link')
@unittest.mock.patch('tmt.utils.Config')
def test_create_link_relation(self, mock_config_tree, mock_add_simple_link) -> None:
mock_config_tree.return_value.fmf_tree = self.config_tree
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
tmt.utils.jira.link(
tmt_objects=[test],
links=tmt.base.Links(data=['verifies:https://issues.redhat.com/browse/TT-262']),
logger=self.logger)
# Load the test object again with the link present
test = tmt.Tree(logger=self.logger, path=self.tmp).tests(names=['tmp/test'])[0]
assert test.link.get('verifies')[0].target == 'https://issues.redhat.com/browse/TT-262'
1 change: 1 addition & 0 deletions tmt.spec
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ metadata specification (L1 and L2) and allows easy test execution.
%pyproject_extras_subpkg -n tmt export-polarion
%pyproject_extras_subpkg -n tmt report-junit
%pyproject_extras_subpkg -n tmt report-polarion
%pyproject_extras_subpkg -n tmt link-jira

%package -n tmt+test-convert
Summary: Dependencies required for tmt test import and export
Expand Down
90 changes: 75 additions & 15 deletions tmt/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import tmt.templates
import tmt.utils
import tmt.utils.git
import tmt.utils.jira
from tmt.checks import Check
from tmt.lint import LinterOutcome, LinterReturn
from tmt.result import Result
Expand Down Expand Up @@ -1291,6 +1292,14 @@ def _get_template_content(template: str, template_type: str) -> str:
force=force,
logger=logger)

if links.get('verifies') and dry is False:
tests = Tree(
path=path,
logger=logger).tests(
names=[f"^{name}$"],
apply_command_line=False)
tmt.utils.jira.link(tmt_objects=tests, links=links, logger=logger)

@property
def manual_test_path(self) -> Path:
assert self.manual, 'Test is not manual yet path to manual instructions was requested'
Expand Down Expand Up @@ -1990,6 +1999,13 @@ def create(
# Override template with data provided on command line
plan_content = Plan.edit_template(plan_content)

# Append link with appropriate relation
links = Links(data=list(cast(list[_RawLink], Plan._opt('link', []))))
if links: # Output 'links' if and only if it is not empty
plan_content += dict_to_yaml({
'link': links.to_spec()
})

for name in names:
(directory, plan) = os.path.split(name)
directory_path = path / directory.lstrip('/')
Expand All @@ -2011,6 +2027,14 @@ def create(
force=force,
logger=logger)

if links.get('verifies') and dry is False:
plans = Tree(
path=path,
logger=logger).plans(
names=[f"^{name}$"],
apply_command_line=False)
tmt.utils.jira.link(tmt_objects=plans, links=links, logger=logger)

def _iter_steps(self,
enabled_only: bool = True,
skip: Optional[list[str]] = None
Expand Down Expand Up @@ -2696,6 +2720,13 @@ def create(
except KeyError:
raise tmt.utils.GeneralError(f"Invalid template '{template}'.")

# Append link with appropriate relation
links = Links(data=list(cast(list[_RawLink], Story._opt('link', []))))
if links: # Output 'links' if and only if it is not empty
story_content += dict_to_yaml({
'link': links.to_spec()
})

for name in names:
# Prepare paths
(directory, story) = os.path.split(name)
Expand All @@ -2718,6 +2749,14 @@ def create(
force=force,
logger=logger)

if links.get('verifies') and dry is False:
stories = Tree(
path=path,
logger=logger).stories(
names=[f"^{name}$"],
apply_command_line=False)
tmt.utils.jira.link(tmt_objects=stories, links=links, logger=logger)

@staticmethod
def overview(tree: 'Tree') -> None:
""" Show overview of available stories """
Expand Down Expand Up @@ -2954,23 +2993,30 @@ def tests(
conditions: Optional[list[str]] = None,
unique: bool = True,
links: Optional[list['LinkNeedle']] = None,
excludes: Optional[list[str]] = None
excludes: Optional[list[str]] = None,
apply_command_line: bool = True
) -> list[Test]:
""" Search available tests """
# Handle defaults, apply possible command line options
logger = logger or self._logger
keys = (keys or []) + ['test']
names = names or []
filters = (filters or []) + list(Test._opt('filters', []))
conditions = (conditions or []) + list(Test._opt('conditions', []))
filters = (filters or [])
conditions = (conditions or [])
# FIXME: cast() - typeless "dispatcher" method
links = (links or []) + [
LinkNeedle.from_spec(value)
for value in cast(list[str], Test._opt('links', []))
]
excludes = (excludes or []) + list(Test._opt('exclude', []))
excludes = (excludes or [])
# Used in: tmt run test --name NAME, tmt test ls NAME...
cmd_line_names: list[str] = list(Test._opt('names', []))
cmd_line_names: list[str] = []

if apply_command_line:
filters += list(Test._opt('filters', []))
conditions += list(Test._opt('conditions', []))
excludes += list(Test._opt('exclude', []))
cmd_line_names = list(Test._opt('names', []))

# Sanitize test names to make sure no name includes control character
cmd_line_names = self.sanitize_cli_names(cmd_line_names)
Expand Down Expand Up @@ -3034,22 +3080,29 @@ def plans(
conditions: Optional[list[str]] = None,
run: Optional['Run'] = None,
links: Optional[list['LinkNeedle']] = None,
excludes: Optional[list[str]] = None
excludes: Optional[list[str]] = None,
apply_command_line: bool = True
) -> list[Plan]:
""" Search available plans """
# Handle defaults, apply possible command line options
logger = logger or (run._logger if run is not None else self._logger)
local_plan_keys = (keys or []) + ['execute']
remote_plan_keys = (keys or []) + ['plan']
names = (names or []) + list(Plan._opt('names', []))
filters = (filters or []) + list(Plan._opt('filters', []))
conditions = (conditions or []) + list(Plan._opt('conditions', []))
names = (names or [])
filters = (filters or [])
conditions = (conditions or [])
# FIXME: cast() - typeless "dispatcher" method
links = (links or []) + [
LinkNeedle.from_spec(value)
for value in cast(list[str], Plan._opt('links', []))
]
excludes = (excludes or []) + list(Plan._opt('exclude', []))
excludes = (excludes or [])

if apply_command_line:
names += list(Plan._opt('names', []))
filters += list(Plan._opt('filters', []))
conditions += list(Plan._opt('conditions', []))
excludes += list(Plan._opt('exclude', []))

# Sanitize plan names to make sure no name includes control character
names = self.sanitize_cli_names(names)
Expand Down Expand Up @@ -3105,21 +3158,28 @@ def stories(
conditions: Optional[list[str]] = None,
whole: bool = False,
links: Optional[list['LinkNeedle']] = None,
excludes: Optional[list[str]] = None
excludes: Optional[list[str]] = None,
apply_command_line: Optional[bool] = True
) -> list[Story]:
""" Search available stories """
# Handle defaults, apply possible command line options
logger = logger or self._logger
keys = (keys or []) + ['story']
names = (names or []) + list(Story._opt('names', []))
filters = (filters or []) + list(Story._opt('filters', []))
conditions = (conditions or []) + list(Story._opt('conditions', []))
names = (names or [])
filters = (filters or [])
conditions = (conditions or [])
# FIXME: cast() - typeless "dispatcher" method
links = (links or []) + [
LinkNeedle.from_spec(value)
for value in cast(list[str], Story._opt('links', []))
]
excludes = (excludes or []) + list(Story._opt('exclude', []))
excludes = (excludes or [])

if apply_command_line:
names += list(Story._opt('names', []))
filters += list(Story._opt('filters', []))
conditions += list(Story._opt('conditions', []))
excludes += list(Story._opt('exclude', []))

# Sanitize story names to make sure no name includes control character
names = self.sanitize_cli_names(names)
Expand Down
Loading

0 comments on commit cddc98e

Please sign in to comment.