Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the link command and linking new tests to jira #2922

Merged
merged 30 commits into from
Sep 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
253fb9c
Add link command and linking to jira on new issues.
tkoscieln May 9, 2024
59a9ec7
Refactor link creation, fix type issues, add docs
tkoscieln May 10, 2024
1db9ff2
Change names in base.py
tkoscieln May 15, 2024
3f5f295
Add small fixes, rework url construction, move dependency to opt
tkoscieln May 16, 2024
5dc9262
Add check for Jira dependency import
tkoscieln May 17, 2024
ddd98f5
Fix unit tests
tkoscieln May 17, 2024
232236a
Fix creation of a path
tkoscieln May 19, 2024
abe03a5
Apply suggestions from code review
martinhoyer Aug 21, 2024
601d982
Minor fixes after rebase
martinhoyer Aug 21, 2024
b1e4b91
squash: minor changes
martinhoyer Aug 22, 2024
6a17902
Rename config attributes and fix tests
tkoscieln Sep 3, 2024
2d5a6c6
Add linking write into object metadata on disk
tkoscieln Sep 17, 2024
42da5fe
Fix naming in docs and various minor fixes in base.py
tkoscieln Sep 17, 2024
04960be
Add docstring to link command
tkoscieln Sep 23, 2024
f13a045
Move linking to utils.jira and use ModuleImporter
tkoscieln Sep 24, 2024
b7cad7b
Fix mistake on rebase
tkoscieln Sep 24, 2024
37b2ea3
squash: addressing some pyright/mypy issues
happz Sep 24, 2024
01d6772
Fix exception raise on missing config
tkoscieln Sep 25, 2024
fdac512
Fix traceback with weird debug level, but ruff is unhappy
psss Sep 25, 2024
1ce86a2
Revert "Fix traceback with weird debug level, but ruff is unhappy"
psss Sep 26, 2024
da3e3c5
Fix the logger problem in a better way
happz Sep 26, 2024
8708cf8
Fix the jira requirements
psss Sep 26, 2024
013c6c2
Adjust the documentation a bit
psss Sep 26, 2024
7463be7
Minor docs adjustments
psss Sep 27, 2024
6b16767
A couple of minor fixes
psss Sep 27, 2024
8c5c1cd
Adjust tests (don't check for the ref)
psss Sep 27, 2024
bd72f2c
Rename config, fix path, make it predictable in tests
psss Sep 27, 2024
3296d56
Match the whole node name when linking created object
psss Sep 27, 2024
7ecd3bb
Rewrite the `jira.py` module
psss Sep 30, 2024
a0208d6
Include a short release not, mention the version
psss Sep 30, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
]
psss marked this conversation as resolved.
Show resolved Hide resolved
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
Loading