Skip to content

Commit

Permalink
Refactor link creation, fix type issues, add docs
Browse files Browse the repository at this point in the history
  • Loading branch information
tkoscieln committed May 10, 2024
1 parent fe0d5ff commit 931fe16
Show file tree
Hide file tree
Showing 6 changed files with 135 additions and 69 deletions.
35 changes: 35 additions & 0 deletions docs/guide.rst
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,41 @@ Sometimes you forget something, or just things may go wrong and
you need another try. In such case add ``-f`` or ``--force`` to
quickly overwrite existing files with the right content.

.. _linking_tmt_objects:

Linking tmt objects
------------------------------------------------------------------
If you create a Jira issue for a bug or a feature, you can link it
to the test or plan that covers it. This can be either done automatically
on creation of a new test or plan, or later on using the ``tmt link`` command.

.. code-block:: shell
tmt link verifies:issues.redhat.com/browse/YOUR-ISSUE tests/core/smoke
For this feature to be enabled, you have to create a configuration node in a
configuration tree. Once the configuration is present, it automatically enables
the linking on its own, no further actions needed.
The configuration has a reserved keywork ``linking`` and should
have following structure:

.. code-block:: yaml
linking:
- type: jira
server: https://issues.redhat.com
service: http://localhost:8000/
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 ``server`` is the URL of said service. The ``service`` is the URL of the
service that presents a 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).

.. _custom_templates:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ dependencies = [
"types-urllib3",
"types-jinja2",
"types-babel",
"types-docutils"
"types-docutils",
]
features = ["all"]

Expand Down
17 changes: 17 additions & 0 deletions tests/unit/test_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1634,3 +1634,20 @@ 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)


@unittest.mock.patch('jira.JIRA.add_simple_link')
@unittest.mock.patch('tmt.utils.Config')
def test_jira_link(mock_config_tree, mock_add_simple_link, root_logger: tmt.log.Logger) -> None:
config = tmt.utils.Config()
config.fmf_tree.find = MagicMock({'linking': [
{'type': 'jira',
'server': 'https://issues.redhat.com',
'service': 'http://localhost:8000/',
'token': ''}]})
mock_config_tree.return_value = config
test = tmt.Tree(logger=root_logger, path=Path(".")).tests()[0]
tmt.utils.jira_link([test], tmt.base.Links(data=['verifies:issues.redhat.com/browse/TT-262']))
result = mock_add_simple_link.call_args.args[1]
# assert ('test-url=https://github.com/teemtee/tmt.git&test-name=/tests/provision/virtual/'
# 'dependencies&test-ref=link-issues-to-jira&format=html') in result['url']
64 changes: 37 additions & 27 deletions tmt/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,7 @@ def _get_template_content(template: str, template_type: str) -> str:
names=[
directory_path.name],
apply_command_line=False)
tmt.utils.jira_link(nodes=[test], links=links)
tmt.utils.jira_link(nodes=test, links=links)

@property
def manual_test_path(self) -> Path:
Expand Down Expand Up @@ -1942,8 +1942,8 @@ def create(
logger=logger).plans(
names=[
directory_path.name],
apply_remote_keys=False)
tmt.utils.jira_link(nodes=[plan_list], links=links)
apply_command_line=False)
tmt.utils.jira_link(nodes=plan_list, links=links)

def _iter_steps(self,
enabled_only: bool = True,
Expand Down Expand Up @@ -2666,8 +2666,8 @@ def create(
logger=logger).stories(
names=[
directory_path.name],
apply_keys=False)
tmt.utils.jira_link(nodes=[story_list], links=links)
apply_command_line=False)
tmt.utils.jira_link(nodes=story_list, links=links)

@staticmethod
def overview(tree: 'Tree') -> None:
Expand Down Expand Up @@ -2911,16 +2911,22 @@ def tests(
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 All @@ -2929,8 +2935,6 @@ 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)]
Expand Down Expand Up @@ -2988,22 +2992,28 @@ def plans(
run: Optional['Run'] = None,
links: Optional[list['LinkNeedle']] = None,
excludes: Optional[list[str]] = None,
apply_remote_keys: Optional[bool] = True
apply_command_line: Optional[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 All @@ -3021,9 +3031,6 @@ def plans(
else:
sources = None

if not apply_remote_keys:
remote_plan_keys = []

# Build the list, convert to objects, sort and filter
plans = [
Plan(
Expand Down Expand Up @@ -3062,21 +3069,27 @@ def stories(
whole: bool = False,
links: Optional[list['LinkNeedle']] = None,
excludes: Optional[list[str]] = None,
apply_keys: Optional[bool] = True
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 All @@ -3094,9 +3107,6 @@ 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
Expand Down
11 changes: 5 additions & 6 deletions tmt/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2101,13 +2101,12 @@ def link(context: Context,
link: list[str],
separate: bool,
) -> None:
nodes: list[list['tmt.base.Core']] = []
nodes: list[Union['tmt.base.Test', 'tmt.base.Plan', 'tmt.base.Story']] = []
for name in names:
if context.obj.tree.tests(names=[name]):
nodes.append(context.obj.tree.tests(names=[name]))
nodes.extend(context.obj.tree.tests(names=[name]))
if context.obj.tree.plans(names=[name]):
nodes.append(context.obj.tree.plans(names=[name]))
nodes.extend(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)
nodes.extend(context.obj.tree.stories(names=[name]))
tmt.utils.jira_link(nodes, tmt.base.Links(data=link[0]), separate)
75 changes: 40 additions & 35 deletions tmt/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -7274,13 +7274,13 @@ def render_rst(text: str, logger: Logger) -> str:


def jira_link(
nodes: list[list['tmt.base.Core']],
nodes: list[Union['tmt.base.Test', 'tmt.base.Plan', 'tmt.base.Story']],
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]:
def create_url(tmt_object: 'tmt.base.Core') -> list[str]:
url: list[str] = []
# Get the fmf id of the object
if isinstance(tmt_object, tmt.base.Test):
fmfid = tmt_object.fmf_id
Expand All @@ -7292,54 +7292,59 @@ def create_url(linking_config: dict[str, str], tmt_object: 'tmt.base.Core',
# 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}'
url.append(f'{tmt_type}-url={fmfid.url}')
url.append(f'{tmt_type}-name={fmfid.name}')
if fmfid.path is not None:
url += f'&{tmt_type}-path={fmfid.path}'
url.append(f'{tmt_type}-path={fmfid.path}')
if fmfid.ref is not None:
url += f'&{tmt_type}-ref={fmfid.ref}'
url.append(f'{tmt_type}-ref={fmfid.ref}')
return url

def construct_url_from_list(url: str, url_parts: list[str]) -> str:
joined_url = '&'.join(url_parts)
joined_url = url + joined_url
# 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
joined_url += '&format=html'
return joined_url

# Setup config tree
config_tree = tmt.utils.Config()
print(config_tree.fmf_tree.find('/user/linking').data)
# Linking is not setup in config, therefore user does not want to use linking
if config_tree.fmf_tree.find('/user/linking') is None:
return
if config_tree.fmf_tree.find('/user/linking').data.get('linking') is None:
return
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):
service_url: list[str] = []
link_object: dict[str, str] = {}
for node in 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])
service_url = create_url(tmt_object=node)
link_object = {
"url": service_url,
"title": f"[tmt_web] Metadata of the {obj_type} covering this issue"
}
"url": construct_url_from_list(
linking_config["service"],
service_url),
"title": f'[tmt_web] Metadata of the {type(node).__name__.lower()}'
f' 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
url_part = create_url(tmt_object=node)
service_url.extend(url_part)
link_object = {
"url": construct_url_from_list(linking_config["service"], service_url),
"title": f'[tmt_web] Metadata of the'
f' {fmf.utils.listed([type(node).__name__.lower() for node in nodes])}'
f' covering this issue'
}
if len(nodes) > 1 and not separate:
# Send request to JIRA when len(nodes) > 1 and not separate, after all nodes were processed
jira.add_simple_link(issue_id, link_object)

0 comments on commit 931fe16

Please sign in to comment.