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 link command and linking to jira on new issues #2922

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from

Conversation

tkoscieln
Copy link
Collaborator

@tkoscieln tkoscieln commented May 9, 2024

Pull Request Checklist

  • implement the feature
  • write the documentation
  • extend the test coverage
  • update the specification
  • adjust plugin docstring
  • modify the json schema
  • mention the version
  • include a release note

@psss psss changed the title Add link command and linking to jira on new issues. Add link command and linking to jira on new issues May 9, 2024
@LecrisUT
Copy link
Contributor

LecrisUT commented May 9, 2024

Could use an example of how this works. Probably this needs to be abstracted a bit for more arbitrary issue providers. Are there no plans yet to strip out some of the tmt plugins in order to minimize the dependencies and tests?

@tkoscieln
Copy link
Collaborator Author

tkoscieln commented May 9, 2024

Could use an example of how this works. Probably this needs to be abstracted a bit for more arbitrary issue providers. Are there no plans yet to strip out some of the tmt plugins in order to minimize the dependencies and tests?

This is currently WIP, hence why it is in Draft. Will provide more information once the initial refactor suggested by @psss in a discussion will be implemented.

@tkoscieln
Copy link
Collaborator Author

Added a section to documentation which should explain what this change aims to introduce. Related Jira issue TT-262.

@LecrisUT
Copy link
Contributor

Thanks for the clarification I kinda understand the setup, and I am more confused about it at the same time. The main idea is to add in the Issue links a link to the tmt test/plan/story that would fix a specific issue. The confusing part is the usage of the service field. This seems to imply that the tmt metadata is served somehow probably as teemtee/web? That part should be advertised a bit more if that is the only linkage that would be supported. What about general audience?

Another question I have is what happens with the links when a test/structure gets refactored. Could the tests be traced back through the git tree to see how it evolves and re-links?

tmt/base.py Outdated
@@ -2862,23 +2903,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: Optional[bool] = True
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the parameter is only a boolean flag, and it never can be None, Optional is not needed. Check unique few lines above, the same case.

tmt/base.py Outdated
@@ -3000,7 +3055,6 @@ def plans(

if not Plan._opt('shallow'):
plans = [plan.import_plan() or plan for plan in plans]

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated change.

tmt/cli.py Outdated
@main.command(name='link')
@pass_context
@click.argument('link', nargs=1, metavar='[RELATION:]TARGET')
@click.argument('names', nargs=-1, metavar='[NAME]...')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NAME] - "name" of what? I'd suggest [TEST|PLAN|STORY]... instead.

link: list[str],
separate: bool,
) -> None:
nodes: list[Union['tmt.base.Test', 'tmt.base.Plan', 'tmt.base.Story']] = []
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tmt.base.Core would make the annotation simpler, unless there's a need for enumerating its child classes.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There was a issue in mypy throwing an error List item 0 has incompatible type "list[Test]"; expected "list[Core]" when the tmt.base.Core annotation was used, that is why it was changed to the Union

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd give it a try, we should be able to address that issue in another way.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for helping with this @happz.

tmt/utils.py Outdated
def jira_link(
nodes: list[Union['tmt.base.Test', 'tmt.base.Plan', 'tmt.base.Story']],
links: 'tmt.base.Links',
separate: Optional[bool] = False) -> None:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A boolean flag, no need for Optional, it will never be set to None.

tmt/utils.py Outdated
url: list[str] = []
# Get the fmf id of the object
if isinstance(tmt_object, tmt.base.Test):
fmfid = tmt_object.fmf_id
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First, I believe you don't have to set fmf_id in every branch - all Core instances should have it, and indeed, all three lines are exactly the same, fmfid = tmt_object.fmf_id.

Second, you may use the class name to generate tmt_type, tmt_object.__class__.__name__.lower() should give you the same outcome.

Together, you should be able to replace this if with two lines handling all possible classes :)

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The difference between server and service is fairly minimal and got me thinking about which one is which. How about renaming these keys, e.g. server => website, jira-website (together with jira-token? Because a mere token - authenticate the user, sure, but against which service, the linking one or Jira?), service => something like tmt-link-service or something making it clear which service it is. At first, I thought it referred to Jira API...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming is hard. I agree that server and service are very similar. I'll try my brainstorm: If we call the config node issue-tracker it could suggest more that the data are related primarily to the issue tracking services so we could use url and token?

issue-tracker:
  - type: jira
    url: https://issues.redhat.com
    token: <YOUR_PERSONAL_JIRA_TOKEN>
    web: http://localhost:8000/

For the tmt web service I'd suggest to use web (consistent with the https://github.com/teemtee/web project) or tmt-web to make it even more specific. Nothing better comes to my mind right now.


.. code-block:: shell

tmt link verifies:issues.redhat.com/browse/YOUR-ISSUE tests/core/smoke
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make schema part of the link from the beginning? Examples at https://tmt.readthedocs.io/en/stable/spec/core.html#link use it like it's nothing to be afraid of. verifies:https://issues.redhat.com/browse/YOUR-ISSUE would be complete, explicitly stated by the user, no space for tmt guess the wrong scheme. If I run my own ticket tracking service in my local network, without https, I wouldn't be able to use tmt link because it would be injecting https:// into my links.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the full url should be used here.

tmt/utils.py Outdated
joined_url = url + joined_url
# Ask for HTML format (WIP, can be changed once FE web server is implemented)
joined_url += '&format=html'
return joined_url
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of suggestions for URL generation: you spend too much time working out the low-level details, "manually" constructing the URL which is error-prone and easy to do incorrectly. I cannot recommend https://docs.python.org/3/library/urllib.parse.html#module-urllib.parse enough :)

  1. create_url() is not creating URL, it's collecting its query components. The name is therefore misleading.
  2. these components are pretty much key/value pairs, collect them as such. If the goal is to collect and construct URL query components, then use the best fitting data type for the goal, do not worry about the fact these components need to be encoded and merged into a string - such tasks are best performed at the boundary between your code and the outside world. The best-fitting data type is a simple dictionary:
    tmt_type = tmt_object.__class__.__name__.lower()
    fmf_id = tmt_object.fmf_id
    
    url_params: dict[str, str] = {
      f'{tmt_type}-url': fmfid.url,
      f'{tmt_type}-name': fmfid.name
    }
    
    if fmf_id.path is not None:
      url_params[f'{tmt_type}-path'] = fmfid.path
    if fmf_id.ref is not None:
      url_params[f'{tmt_type}-ref'] = fmfid.ref
    
    return url_params
  3. construct_url_from_list(url: str, url_parts: list[str]) takes url parameter which is unexpected - I'd suggest calling this one baseurl, it's a fairly common approach when you need to construct a URL from some starting website URL plus a path and/or parameters.
  4. joined_url += '&format=html' - can we move this one to the create_url() function? It seems to be yet another parameter, url_params['format'] = 'html'.
  5. https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlencode is exactly the helper we need to take parameters and convert them into a part of URL. It takes care of encoding weird characters, joining them with &, and all that stuff. When doing it manually, you always have to remember to put & at the right place, and you do not encode characters that need to be encoded, like spaces or /.
  6. joined_url = url + joined_url - and if the service base URL does not end with /, this will produce invalid URL.
  7. the same applies to parsing URL, see https://docs.python.org/3/library/urllib.parse.html#urllib.parse.urlparse - much better choice than split() and indexing. For one call, you get a named tuple and can inspect schema, hostname, all the components, with no chance of getting an index wrong by mistake.

As you can see, it looks trivial, but there are a lot of traps, encoding being the biggest one. Luckily, Python provides a very helpful library. Eventually, you can end up with something much simpler and safer, e.g.

def create_url_params(tmt_object: 'tmt.base.Core') -> dict[str, str]:
   tmt_type = tmt_object.__class__.__name__.lower()
   fmf_id = tmt_object.fmf_id

   url_params: dict[str, str] = {
     'format': 'html',
     f'{tmt_type}-url': fmfid.url,
     f'{tmt_type}-name': fmfid.name
   }

   if fmf_id.path is not None:
     url_params[f'{tmt_type}-path'] = fmfid.path

   if fmf_id.ref is not None:
     url_params[f'{tmt_type}-ref'] = fmfid.ref

   return url_params

def create_url(baseurl: str, url_params: dict[str, str]) -> str:
    return urllib.parse.urljoin(baseurl, urllib.parse.urlencode(url_params))

I'm not sure what's the purpose of service_url as a list, it seems to collect URL parameters for various nodes? if len(nodes) > 1 and not separate:, url_part = create_url(tmt_object=node) -> service_url.extend(url_part), in the next iteration, the list is empty and will get yet another string from new call to create_url(tmt_object=node)?

# 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you know? :) The user just called tmt link, that seems to me like a solid argument that the user wanted to use linking :) Or the user called test create --link ..., then I guess the user is interested in linking... It's hard to tell from here, but most likely the opposite is true - jira_link would not get called if somebody didn't try to link things to Jira.

My two cents: the function does too many things. It takes care of links itself, but also prepares its own inputs, reads configuration, tries to look for a linking stanza in it, then picks a single link out of links - verifies only, no other relation is supported. It has so many inputs. I would recommend splitting it in two:

  • a function that focuses on creating links and nothing else. It's given a linking configuration, a single Link instance, and a list of nodes. And the separate, IIUIC. It has a simple task, talk to Jira, construct a linking service URL, and submit changes to Jira. No looking around, no external state to load or test, nothing to decide on its own, input-driven.
  • a function that manages these links, some kind of "ticket linking manager", something that's responsible for adding links to (various) services like Jira or Bugzilla or Trello, whatever. Give it nodes, give it a list of Link instances. It loads the configuration, finds what entries are there for linking, call the respective linking function for them and the given links - type: jira => jira_link(), raise an error for unknown type in the linking entry, unsupported. It may return the list of successfully processed links and leave it up to the CLI and base.py to decide whether it's fine or not that no Jira link was actually created, or it may raise an exception - or re-raise an exception raised by jira_link() if the linking fails.

I understand it's a WiP and a prototype, yet I'd recommend focusing on the separation of concerns. Otherwise, it will become harder and harder to extend and test.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I think the separation to two functions is a good idea, I am not sure if the second function you mentioned should take a list of Link instances as a argument. AFAIK, currently, the idea was that we link in a one-to-many relation, several tmt objects can be linked to one issue. Also, supporting more types other than Jira is out of scope of this PR as well, but I agree that it is a good idea to refactor the code now to make it ready for adding more types in the future easily.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach outlined by @happz seems reasonable to me. Regarding the question around providing a list of Link instances: Currently the command line syntax for tmt link takes only a single link as input:

tmt link verifies:https://foo.com/a/b/c /plans/core /tests/smoke

It is true that tmt {test|plan|story} create commands can take multiple --link options so there seems to be use case also for multiple links. Thinking about it, should we change the design of tmt link to allow multiple links as well? Or are we ok with the currently proposed way?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you know? :) The user just called tmt link, that seems to me like a solid argument that the user wanted to use linking :) Or the user called test create --link ..., then I guess the user is interested in linking...

There are actually two parts of the linking action:

  • add the link to the test, plan or story metadata, this is now done by tmt * create only
  • link the web service url to the issue tracking service

It's perfectly fine if user does not have issues trackers defined in their config to only perform the first step. So calling tmt link with no config would also make sense for example for quickly updating metadata on disk for several tests at once.

Implementation of the second step should not be to much difficult as fmf already supports a comfortable way to modify existing metadata on disk using a with statement. Here's an example for inspiration:

tmt/tmt/export/nitrate.py

Lines 438 to 439 in e6a0964

with test.node as data:
data["extra-nitrate"] = nitrate_case.identifier

tmt/base.py Outdated
@@ -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(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests instead of test.

tmt/base.py Outdated
@@ -1920,6 +1936,15 @@ def create(
force=force,
logger=logger)

if links.get('verifies') and dry is False:
plan_list = Tree(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

plans instead of plan_list.

tmt/base.py Outdated
@@ -2628,6 +2660,15 @@ def create(
force=force,
logger=logger)

if links.get('verifies') and dry is False:
story_list = Tree(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And stories instead of story_list :)

pyproject.toml Outdated
@@ -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"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we make it a separate package for the 'link' functionality and add dependency just there?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you elaborate please? How would that look?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Write it as a separate project. It can be in a different repo like what I'm working on with tmt-cmake or it can live within the current git repo, e.g. hatch contains hatchling as a sub-project.

w.r.t. how to add functionality to tmt, you can use project.entry-points."tmt.plugin".

Should note that we still haven't pinned down how to make it available for testing-farm 1, but let's investigate it when it's closer to production.

Side-note, this feature requires teemtee/web doesn't it? It would be nice to incorporate these 2 together. An abstract link interface should be available as @happz mentioned so this might be a bit more complicated to have it in a completely separate repo right now (not impossible, I do that in tmt-cmake)

Footnotes

  1. https://github.com/teemtee/tmt/issues/2758#issuecomment-1995473532

@@ -65,6 +65,9 @@ report-polarion = [
"tmt[report-junit]",
"tmt[export-polarion]",
]
link-jira = [
"jira>=3.2.0",
]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new optional dependency should be also created as an rpm package. The relevant spec file lines are here:

tmt/tmt.spec

Lines 29 to 31 in e6a0964

%pyproject_extras_subpkg -n tmt export-polarion
%pyproject_extras_subpkg -n tmt report-junit
%pyproject_extras_subpkg -n tmt report-polarion

Copy link
Collaborator

@psss psss left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for working on this! Overall looks very good. Added some comments and suggestions.

@@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Linking tmt objects
Linking Issues

Everything here's about tmt objects, the issues are the interesting part here. Please, update the link anchor above as well.


.. code-block:: shell

tmt link verifies:issues.redhat.com/browse/YOUR-ISSUE tests/core/smoke
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, the full url should be used here.

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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about about the proper name for the config node. Currently we only have /user/plan which is used in tmt try for pointing to default plan(s). Trying to imagine more... I guess there could be /user/options for storing default command line options like /user/options/verbose or /user/options/color or similar.

The list of issue tracking servers does not fit there completely I'd say. What about storing them in the root of the config tree as /linking? Instructions for setting up the config could be easier (once we have the config section itself documented, but let's not block on that now):

Create a file ~/.config/tmt/linking.fmf with the list of issue tracking services and corresponding tokens:

  - type: jira
    server: https://issues.redhat.com
    service: http://localhost:8000/
    token: <YOUR_PERSONAL_JIRA_TOKEN>

Or, perhaps, if this is supposed to be list of issues trackers, we could/should call it issue-tracker.fmf instead?

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.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming is hard. I agree that server and service are very similar. I'll try my brainstorm: If we call the config node issue-tracker it could suggest more that the data are related primarily to the issue tracking services so we could use url and token?

issue-tracker:
  - type: jira
    url: https://issues.redhat.com
    token: <YOUR_PERSONAL_JIRA_TOKEN>
    web: http://localhost:8000/

For the tmt web service I'd suggest to use web (consistent with the https://github.com/teemtee/web project) or tmt-web to make it even more specific. Nothing better comes to my mind right now.

@@ -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.')
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
help='Link to the relevant issues.')
help='Link created plan to the relevant issues.')



def import_jira() -> None:
""" Import polarion Python Jira library """
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
""" Import polarion Python Jira library """
""" Import Python Jira library """

from jira import JIRA
except ImportError:
raise GeneralError(
"Install 'tmt+link-jira' to use the Jira linking")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"Install 'tmt+link-jira' to use the Jira linking")
"Install 'tmt+link-jira' to use the Jira linking.")

See the recommendations for the consistent message format.

""" Link the object to Jira issue and create the URL to tmt web service """
import_jira()

def create_url_params(tmt_object: 'tmt.base.Core') -> dict[str, str]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, add a docstring.

# 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The approach outlined by @happz seems reasonable to me. Regarding the question around providing a list of Link instances: Currently the command line syntax for tmt link takes only a single link as input:

tmt link verifies:https://foo.com/a/b/c /plans/core /tests/smoke

It is true that tmt {test|plan|story} create commands can take multiple --link options so there seems to be use case also for multiple links. Thinking about it, should we change the design of tmt link to allow multiple links as well? Or are we ok with the currently proposed way?

# 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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you know? :) The user just called tmt link, that seems to me like a solid argument that the user wanted to use linking :) Or the user called test create --link ..., then I guess the user is interested in linking...

There are actually two parts of the linking action:

  • add the link to the test, plan or story metadata, this is now done by tmt * create only
  • link the web service url to the issue tracking service

It's perfectly fine if user does not have issues trackers defined in their config to only perform the first step. So calling tmt link with no config would also make sense for example for quickly updating metadata on disk for several tests at once.

Implementation of the second step should not be to much difficult as fmf already supports a comfortable way to modify existing metadata on disk using a with statement. Here's an example for inspiration:

tmt/tmt/export/nitrate.py

Lines 438 to 439 in e6a0964

with test.node as data:
data["extra-nitrate"] = nitrate_case.identifier

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants