From fe0d5ff4d45ff13c0cf131fc0b568d18d04d99da Mon Sep 17 00:00:00 2001 From: Tom Koscielniak Date: Thu, 9 May 2024 13:00:42 +0200 Subject: [PATCH] Add link command and linking to jira on new issues. --- pyproject.toml | 3 ++- tmt/base.py | 59 +++++++++++++++++++++++++++++++++++++--- tmt/cli.py | 30 +++++++++++++++++++++ tmt/utils.py | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c269493502..831be2036d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ # F39 / PyPI "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, <3.0", # 1.26.16 / 2.0.4 + "jira" ] [project.optional-dependencies] @@ -136,7 +137,7 @@ dependencies = [ "types-urllib3", "types-jinja2", "types-babel", - "types-docutils", + "types-docutils" ] features = ["all"] diff --git a/tmt/base.py b/tmt/base.py index 34e50c6caf..fd02778703 100644 --- a/tmt/base.py +++ b/tmt/base.py @@ -1261,6 +1261,15 @@ def _get_template_content(template: str, template_type: str) -> str: force=force, logger=logger) + if links.get('verifies') and dry is False: + test = Tree( + path=path, + logger=logger).tests( + names=[ + directory_path.name], + apply_command_line=False) + tmt.utils.jira_link(nodes=[test], links=links) + @property def manual_test_path(self) -> Path: assert self.manual, 'Test is not manual yet path to manual instructions was requested' @@ -1899,6 +1908,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('/') @@ -1920,6 +1936,15 @@ def create( force=force, logger=logger) + if links.get('verifies') and dry is False: + plan_list = Tree( + path=path, + logger=logger).plans( + names=[ + directory_path.name], + apply_remote_keys=False) + tmt.utils.jira_link(nodes=[plan_list], links=links) + def _iter_steps(self, enabled_only: bool = True, skip: Optional[list[str]] = None @@ -2606,6 +2631,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) @@ -2628,6 +2660,15 @@ def create( force=force, logger=logger) + if links.get('verifies') and dry is False: + story_list = Tree( + path=path, + logger=logger).stories( + names=[ + directory_path.name], + apply_keys=False) + tmt.utils.jira_link(nodes=[story_list], links=links) + @staticmethod def overview(tree: 'Tree') -> None: """ Show overview of available stories """ @@ -2862,7 +2903,8 @@ 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: Optional[bool] = True ) -> list[Test]: """ Search available tests """ # Handle defaults, apply possible command line options @@ -2887,6 +2929,8 @@ def name_filter(nodes: Iterable[fmf.Tree]) -> list[fmf.Tree]: """ Filter nodes based on names provided on the command line """ if not cmd_line_names: return list(nodes) + if not apply_command_line: + return list(nodes) return [ node for node in nodes if any(re.search(name, node.name) for name in cmd_line_names)] @@ -2943,7 +2987,8 @@ 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_remote_keys: Optional[bool] = True ) -> list[Plan]: """ Search available plans """ # Handle defaults, apply possible command line options @@ -2976,6 +3021,9 @@ def plans( else: sources = None + if not apply_remote_keys: + remote_plan_keys = [] + # Build the list, convert to objects, sort and filter plans = [ Plan( @@ -3000,7 +3048,6 @@ def plans( if not Plan._opt('shallow'): plans = [plan.import_plan() or plan for plan in plans] - return self._filters_conditions( sorted(plans, key=lambda plan: plan.order), filters, conditions, links, excludes) @@ -3014,7 +3061,8 @@ 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_keys: Optional[bool] = True ) -> list[Story]: """ Search available stories """ # Handle defaults, apply possible command line options @@ -3046,6 +3094,9 @@ def stories( else: sources = None + if not apply_keys: + keys = None + # Build the list, convert to objects, sort and filter stories = [ Story(node=story, tree=self, logger=logger.descend()) for story diff --git a/tmt/cli.py b/tmt/cli.py index 1d3d0f7c5f..496f416643 100644 --- a/tmt/cli.py +++ b/tmt/cli.py @@ -1160,6 +1160,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 to the relevant issues.') @verbosity_options @force_dry_options def plans_create( @@ -1345,6 +1348,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 to the relevant issues.') @verbosity_options @force_dry_options def stories_create( @@ -2081,3 +2087,27 @@ def completion_fish(context: Context, install: bool, **kwargs: Any) -> None: Setup shell completions for fish. """ setup_completion('fish', install) + + +@main.command(name='link') +@pass_context +@click.argument('link', nargs=1, metavar='[RELATION:]TARGET') +@click.argument('names', nargs=-1, metavar='[NAME]...') +@option( + '--separate', is_flag=True, + help="Create linking separately for multiple passed objects.") +def link(context: Context, + names: list[str], + link: list[str], + separate: bool, + ) -> None: + nodes: list[list['tmt.base.Core']] = [] + for name in names: + if context.obj.tree.tests(names=[name]): + nodes.append(context.obj.tree.tests(names=[name])) + if context.obj.tree.plans(names=[name]): + nodes.append(context.obj.tree.plans(names=[name])) + if context.obj.tree.stories(names=[name]): + nodes.append(context.obj.tree.stories(names=[name])) + print(nodes) + tmt.utils.jira_link(nodes, tmt.base.Links(data=link), separate) diff --git a/tmt/utils.py b/tmt/utils.py index 9bd94edc0d..b5c700d1e4 100644 --- a/tmt/utils.py +++ b/tmt/utils.py @@ -60,6 +60,7 @@ import urllib3.exceptions import urllib3.util.retry from click import echo, style, wrap_text +from jira import JIRA from ruamel.yaml import YAML, scalarstring from ruamel.yaml.comments import CommentedMap from ruamel.yaml.parser import ParserError @@ -7270,3 +7271,75 @@ def render_rst(text: str, logger: Logger) -> str: document.walkabout(visitor) return visitor.rendered + + +def jira_link( + nodes: list[list['tmt.base.Core']], + links: 'tmt.base.Links', + separate: Optional[bool] = False) -> None: + """ Link the object to Jira issue and create the URL to tmt web service """ + + def create_url(linking_config: dict[str, str], tmt_object: 'tmt.base.Core', + append: bool = False, existing_url: Optional[str] = None) -> tuple[str, str]: + # Get the fmf id of the object + if isinstance(tmt_object, tmt.base.Test): + fmfid = tmt_object.fmf_id + tmt_type = "test" + elif isinstance(tmt_object, tmt.base.Plan): + fmfid = tmt_object.fmf_id + tmt_type = "plan" + elif isinstance(tmt_object, tmt.base.Story): + # TODO: not supported by the service yet + fmfid = tmt_object.fmf_id + tmt_type = "story" + if append and existing_url is not None: + url = existing_url + url += f'&{tmt_type}-url={fmfid.url}&{tmt_type}-name={fmfid.name}' + else: + url = f'{linking_config["service"]}?{tmt_type}-url={fmfid.url}&{tmt_type}-name={fmfid.name}' + if fmfid.path is not None: + url += f'&{tmt_type}-path={fmfid.path}' + if fmfid.ref is not None: + url += f'&{tmt_type}-ref={fmfid.ref}' + # Ask for HTML format (WIP, can be changed once FE web server is implemented) + if not append: + url = url + '&format=html' + return url, tmt_type + + # Setup config tree + config_tree = tmt.utils.Config() + linking_config = config_tree.fmf_tree.find('/user/linking').data.get('linking')[0] + verifies = links.get('verifies')[0] + target = verifies.to_dict()['target'] + # Parse the target url + jira_server_url = 'https://' + target.split('/')[0] + issue_id = target.split('/')[-1] + jira = JIRA(server=jira_server_url, token_auth=linking_config['token']) + service_url: str = "" + for index, node in enumerate(nodes): + # Single object in list of nodes = creating new object + # or linking multiple existing separately + if len(nodes) == 1 or (len(nodes) > 1 and separate): + service_url, obj_type = create_url(linking_config=linking_config, tmt_object=node[0]) + link_object = { + "url": service_url, + "title": f"[tmt_web] Metadata of the {obj_type} covering this issue" + } + jira.add_simple_link(issue_id, link_object) + if len(nodes) > 1 and not separate: + if index == 0: + url_part, _ = create_url(linking_config=linking_config, tmt_object=node[0]) + service_url = url_part + elif index == len(nodes) - 1: + url_part, _ = create_url(linking_config=linking_config, + tmt_object=node[0], append=True, existing_url=service_url) + service_url = url_part + link_object = { + "url": service_url, + "title": "[tmt_web] Metadata of the tests/plans/stories covering this issue" + } + jira.add_simple_link(issue_id, link_object) + else: + url_part, _ = create_url(linking_config=linking_config, + tmt_object=node[0], append=True, existing_url=service_url) + service_url = url_part