diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3d37f4da07..7d09f080f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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" @@ -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" diff --git a/docs/guide.rst b/docs/guide.rst index dc9a4a5c77..8a402eb5c7 100644 --- a/docs/guide.rst +++ b/docs/guide.rst @@ -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: + +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 +`_ +(please note that this can vary if you use custom Jira instance). + +.. versionadded:: 1.37 + + .. _anchors-aliases: Anchors and Aliases diff --git a/docs/releases.rst b/docs/releases.rst index c8848afa4a..8765a9a657 100644 --- a/docs/releases.rst +++ b/docs/releases.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index e032ccf10c..a8d080a9f4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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]", @@ -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 = [ diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 3db17c4c29..ac057542dc 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -2,6 +2,7 @@ import logging import queue import re +import shutil import signal import textwrap import threading @@ -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, @@ -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' diff --git a/tmt.spec b/tmt.spec index 58bf93b674..bc9e47e6aa 100644 --- a/tmt.spec +++ b/tmt.spec @@ -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 diff --git a/tmt/base.py b/tmt/base.py index 7aa07407c1..ee984bff15 100644 --- a/tmt/base.py +++ b/tmt/base.py @@ -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 @@ -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' @@ -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('/') @@ -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 @@ -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) @@ -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 """ @@ -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) @@ -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) @@ -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) diff --git a/tmt/cli.py b/tmt/cli.py index 7f72fd21bf..f81eb22d65 100644 --- a/tmt/cli.py +++ b/tmt/cli.py @@ -27,6 +27,7 @@ import tmt.templates import tmt.trying import tmt.utils +import tmt.utils.jira import tmt.utils.rest from tmt.options import Deprecated, create_options_decorator, option from tmt.utils import Command, Path @@ -824,7 +825,7 @@ def tests_lint( help=f'Test script template ({_script_templates}).') @option( '--link', metavar='[RELATION:]TARGET', multiple=True, - help='Link to the relevant issues.') + help='Link created test to the relevant issues.') @verbosity_options @force_dry_options def tests_create( @@ -1272,6 +1273,9 @@ def plans_lint( @option( '--finish', metavar='YAML', multiple=True, help='Finish phase content in yaml format.') +@option( + '--link', metavar='[RELATION:]TARGET', multiple=True, + help='Link created plan to the relevant issues.') @verbosity_options @force_dry_options def plans_create( @@ -1457,6 +1461,9 @@ def stories_show( '-t', '--template', metavar='TEMPLATE', prompt=f'Template ({_story_templates})', help=f'Story template ({_story_templates}).') +@option( + '--link', metavar='[RELATION:]TARGET', multiple=True, + help='Link created story to the relevant issues.') @verbosity_options @force_dry_options def stories_create( @@ -2229,3 +2236,38 @@ def completion_zsh(context: Context, install: bool, **kwargs: Any) -> None: def completion_fish(context: Context, install: bool, **kwargs: Any) -> None: """ Setup shell completions for fish """ setup_completion('fish', install, context) + + +@main.command(name='link') +@pass_context +@click.argument('link', nargs=1, metavar='[RELATION:]TARGET') +@click.argument('names', nargs=-1, metavar='[TEST|PLAN|STORY]...') +@option( + '--separate', is_flag=True, + help="Create linking separately for multiple passed objects.") +def link(context: Context, + names: list[str], + link: str, + separate: bool, + ) -> None: + """ + Create a link to tmt web service in an issue tracking software. + + Using the specified target, a link will be generated and added + to an issue. Link is generated from names of tmt objects + passed in arguments and configuration file. + """ + + tmt_objects = ( + context.obj.tree.tests(names=list(names)) + + context.obj.tree.plans(names=list(names)) + + context.obj.tree.stories(names=list(names))) + + if not tmt_objects: + raise tmt.utils.GeneralError("No test, plan or story found for linking.") + + tmt.utils.jira.link( + tmt_objects=tmt_objects, + links=tmt.base.Links(data=link), + separate=separate, + logger=context.obj.logger) diff --git a/tmt/utils/jira.py b/tmt/utils/jira.py new file mode 100644 index 0000000000..b52fba34b0 --- /dev/null +++ b/tmt/utils/jira.py @@ -0,0 +1,226 @@ +import urllib.parse +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Optional, Union, cast + +import fmf.utils + +import tmt.base +import tmt.log +import tmt.utils +from tmt.plugins import ModuleImporter + +if TYPE_CHECKING: + import jira + +# Config section item for issue trackers +IssueTracker = dict[Any, Any] + +# Test, plan or story +TmtObject = Union['tmt.base.Test', 'tmt.base.Plan', 'tmt.base.Story'] + + +import_jira: ModuleImporter['jira'] = ModuleImporter( # type: ignore[valid-type] + 'jira', + tmt.utils.ReportError, + "Install 'tmt+link-jira' to use the Jira linking.") + + +def prepare_url_params(tmt_object: 'tmt.base.Core') -> dict[str, str]: + """ + Prepare url parameters prefixed with tmt object type + + This is the format in which the tmt web API accepts the + specification of the objects to be displayed to the user. + """ + + tmt_type = tmt_object.__class__.__name__.lower() + fmf_id = tmt_object.fmf_id + + url_params: dict[str, Any] = { + f'{tmt_type}-url': fmf_id.url, + f'{tmt_type}-name': fmf_id.name, + } + + if fmf_id.path: + url_params[f'{tmt_type}-path'] = fmf_id.path + if fmf_id.ref: + url_params[f'{tmt_type}-ref'] = fmf_id.ref + + return url_params + + +class JiraInstance: + """ A Jira instance configured with url and token """ + + def __init__(self, issue_tracker: IssueTracker, logger: tmt.log.Logger): + """ Initialize Jira instance from the issue tracker config """ + + def assert_string(key: str) -> str: + value = issue_tracker.get(key) + if not isinstance(value, str): + raise tmt.utils.GeneralError( + f"Invalid '{key}' value '{value}' in issue tracker config.") + return value + + self.url: str = assert_string("url") + self.tmt_web_url: str = assert_string("tmt-web-url") + self.token: str = assert_string("token") + + self.logger = logger + jira_module = import_jira(logger) + + # ignore[attr-defined]: it is defined, but mypy seems to fail + # detecting it correctly. + self.jira = jira_module.JIRA( # type: ignore[attr-defined] + server=self.url, + token_auth=self.token) + + @classmethod + def from_issue_url( + cls, + issue_url: str, + logger: tmt.log.Logger) -> Optional['JiraInstance']: + """ Search configured issues trackers for matching Jira instance """ + + # Check for the 'link' config section, exit if config missing + try: + config_tree = tmt.utils.Config().fmf_tree + link_config = cast(Optional[fmf.Tree], config_tree.find('/link')) + except tmt.utils.MetadataError: + return None + if not link_config: + return None + + # Check the list of configured issues trackers + issue_trackers: Any = link_config.data.get('issue-tracker') + + if not issue_trackers: + raise tmt.utils.GeneralError( + "No 'issue-tracker' section found in the 'link' config.") + + if not isinstance(issue_trackers, list): + raise tmt.utils.GeneralError( + "The 'issue-tracker' section should be a 'list'.") + + # Find Jira instance matching the issue url + issue_tracker: Any + for issue_tracker in issue_trackers: + if not isinstance(issue_tracker, dict): + raise tmt.utils.GeneralError( + "Issue tracker config should be a 'dict'.") + + # Tracker type must match + issue_tracker_type: Any = issue_tracker.get("type") + if not isinstance(issue_tracker_type, str) or issue_tracker_type != "jira": + continue + + # Issue url must match + jira_server_url: Any = issue_tracker.get("url") + if not isinstance(jira_server_url, str): + raise tmt.utils.GeneralError( + "Issue tracker 'url' should be a string.") + + if issue_url.startswith(jira_server_url): + return JiraInstance(cast(IssueTracker, issue_tracker), logger=logger) + + return None + + def add_link_to_issue( + self, + issue_url: str, + tmt_objects: Sequence[TmtObject]) -> None: + """ Link one or more tmt objects to the given Jira issue """ + + # Prepare a nice title for the link + title = "tmt: " + fmf.utils.listed( + [tmt_object.name for tmt_object in tmt_objects]) + + # Prepare the tmt web service link from all tmt objects + web_link_parameters: dict[str, str] = {} + for tmt_object in tmt_objects: + web_link_parameters.update(prepare_url_params(tmt_object)) + web_link = urllib.parse.urljoin( + self.tmt_web_url, + "?" + urllib.parse.urlencode(web_link_parameters)) + + # Add link to the issue + issue_id = issue_url.split('/')[-1] + self.jira.add_simple_link(issue_id, {"url": web_link, "title": title}) + self.logger.print(f"Add link '{title}' to Jira issue '{issue_url}'.") + + +def save_link_to_metadata( + tmt_object: TmtObject, + link: 'tmt.base.Link', + logger: tmt.log.Logger) -> None: + """ Store the link into the object metadata on disk """ + # Try to add the link relation to object's data if it is not already there + # + # cast & ignore: data is basically a container with test/plan/story + # metadata. As such, it has a lot of keys and values of + # various data types. + with tmt_object.node as data: # type: ignore[reportUnknownVariableType,unused-ignore] + data = cast(dict[str, Any], data) + link_data = {link.relation: link.target} + + # Add the 'link' section + if "link" not in data: + logger.print(f"Add link '{link.target}' to '{tmt_object.name}'.") + data["link"] = [link_data] + return + + # Update the existing 'link' section + if link_data not in data["link"]: + logger.print(f"Add link '{link.target}' to '{tmt_object.name}'.") + data['link'].append(link_data) + else: + logger.print(f"Link '{link.target}' already present in '{tmt_object.name}'.") + + +def link( + *, + tmt_objects: Sequence[TmtObject], + links: 'tmt.base.Links', + separate: bool = False, + logger: tmt.log.Logger) -> None: + """ + Link provided tmt object(s) with related Jira issue(s) + + The link is added to the following two locations: + + 1. test, plan or story metadata on disk (always) + 2. tmt web link added to the Jira issue (if configured) + + :param tmt_objects: list of tmt tests, plan or stories to be linked + :param links: target jira issues to be linked + :param separate: by default a single link is created for all + provided tmt objects (e.g. test + plan covering an issue), if + True, separate links will be created for each tmt object + :param logger: a logger instance for logging + """ + + # TODO: Shall we cover all relations instead? + for link in links.get("verifies"): + + # Save the link to test/plan/story metadata on disk + for tmt_object in tmt_objects: + save_link_to_metadata(tmt_object, link, logger) + + # Detect Jira instance based on the issue url + if not isinstance(link.target, str): + continue + jira_instance = JiraInstance.from_issue_url(issue_url=link.target, logger=logger) + if not jira_instance: + logger.debug(f"No Jira instance found for issue '{link.target}'.") + continue + + # Link each provided test, plan or story separately + # (e.g. the issue is covered by several individual tests) + if separate: + for tmt_object in tmt_objects: + jira_instance.add_link_to_issue(link.target, [tmt_object]) + + # Link all provided tests, plan or stories with a single link + # (e.g. the issue is covered by a test run under the given plan) + else: + jira_instance.add_link_to_issue(link.target, tmt_objects)