diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc1fbee --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Ignore CPython artefacts +__pycache__/ +*.pyc + +# Ignore test artefacts +.coverage +.pytest_cache/ + +# Ignore pip artefacts +*.egg-info/ diff --git a/LpToJira/lp_to_jira.py b/LpToJira/lp_to_jira.py index 2ec2ae6..4eac5a8 100755 --- a/LpToJira/lp_to_jira.py +++ b/LpToJira/lp_to_jira.py @@ -3,10 +3,10 @@ # create a new Entry in JIRA in a given project -import sys import os +import argparse +import textwrap -from optparse import OptionParser from datetime import datetime, timedelta from launchpadlib.launchpad import Launchpad @@ -42,24 +42,34 @@ def get_lp_bug(lp, bug_number): """Make sure the bug ID exists, return bug""" bug = None + + if lp is None: + return bug + try: bug = lp.bugs[bug_number] + except KeyError: print("Couldn't find the Launchpad bug {}".format(bug_number)) return bug -def get_lp_bug_pkg(lp, bug): - """From a LP bug, get its package""" +def get_lp_bug_pkg(bug): + """ + From a LP bug, get its package + a Launchpad bug may impact multiple packages, + this function is pretty unprecise as it returns only the latest package + TODO: probably do something about this, return a list or fail if multiple + packages. + """ bug_pkg = None - if len(bug.bug_tasks) == 1: - bug_pkg = bug.bug_tasks[0].bug_target_name.split()[0] - else: - for task in bug.bug_tasks: - if "(Ubuntu)" in task.bug_target_name: - bug_pkg = task.bug_target_name.split()[0] + + # Only return bug from Ubuntu (will return the last one if multiple pkgs + for task in bug.bug_tasks: + if "(Ubuntu" in task.bug_target_name: + bug_pkg = task.bug_target_name.split()[0] return bug_pkg @@ -117,7 +127,7 @@ def build_jira_issue(lp, bug, project_id): """Builds and return a dict to create a Jira Issue from""" # Get bug info from LP - bug_pkg = get_lp_bug_pkg(lp, bug) + bug_pkg = get_lp_bug_pkg(bug) # Build the Jira Issue from the LP info issue_dict = { @@ -169,107 +179,109 @@ def lp_to_jira_bug(lp, jira, bug, project_id, opts): bug.lp_save() -def main(): - usage = """\ -usage: lp-to-jira [options] bug-id project-id - -Create JIRA entry for a given Launchpad bug ID - -options: - -e, --exists" - Look if the Launchpad Bug has alreaady been imported - print the JIRA issue ID if found - -l, --label LABEL - Add LABEL to the JIRA issue after creation - -s SYNC_PROJECT_BUGS, --sync_project_bugs=SYNC_PROJECT_BUGS - The name of the LP Project. This will bring in every - bug from your project if you do not also specify days - -d DAYS, --days=DAYS - Only look for LP Bugs in the past n days - --no-lp-tag - -Examples: - lp-to-jira 3215487 FR - lp-to-jira -e 3215487 FR - lp-to-jira -l ubuntu-meeting 3215487 PR - lp-to-jira -s ubuntu -d 3 IQA -""" - - opt_parser = OptionParser(usage) - opt_parser.add_option( - '-l', '--label', - dest='label', +def main(args=None): + opt_parser = argparse.ArgumentParser( + description="A script create JIRA issue from Launchpad bugs", + formatter_class=argparse.RawTextHelpFormatter, + epilog=textwrap.dedent('''\ + Examples: + lp-to-jira 3215487 FR + lp-to-jira -e 3215487 FR + lp-to-jira -l ubuntu-meeting 3215487 PR + lp-to-jira -s ubuntu -d 3 IQA + ''') ) - opt_parser.add_option( + opt_parser.add_argument( + 'bug', type=int, + # Somewhat hacky way to allow -s option to not require bug id + # -s (sync) is an optional parameter that doesn't require bug id + default=0, + nargs='?', + help="The Launchpad numeric bug ID") + opt_parser.add_argument( + 'project', type=str, + help="The JIRA project string key") + opt_parser.add_argument( + '-l', + '--label', + dest='label', + help='Add LABEL to the JIRA issue after creation') + opt_parser.add_argument( '-e', '--exists', dest='exists', - action='store_true' + action='store_true', + help=textwrap.dedent(''' + Look if the Launchpad Bug has alreaady been imported + print the JIRA issue ID if found + ''') ) - - opt_parser.add_option( + opt_parser.add_argument( '-s', '--sync_project_bugs', dest='sync_project_bugs', action='store', type=str, - help='Adds all bugs from a specified LP Project to specified Jira board' - ' if they are not already on the Jira board.' - ' Use --days to narrow down bugs' + help=textwrap.dedent(''' + Adds all bugs from a specified LP Project to specified Jira board + if they are not already on the Jira board. + Use --days to narrow down bugs + ''') ) - - opt_parser.add_option( + opt_parser.add_argument( '-d', '--days', dest='days', action='store', type=int, help='Only look for LP Bugs in the past n days' ) - - opt_parser.add_option( + opt_parser.add_argument( '--no-lp-tag', dest='no_lp_tag', action='store_true', help='Do not add tag to LP Bug' ) - opts, args = opt_parser.parse_args() + opts = opt_parser.parse_args(args) + + if (opts.bug == 0 and not opts.sync_project_bugs): + opt_parser.print_usage() + print('lp-to-jira: error: the follow argument is required: bug') + return 1 # Connect to Launchpad API # TODO: catch exception if the Launchpad API isn't open snap_home = os.getenv("SNAP_USER_COMMON") if snap_home: - credential_store = UnencryptedFileCredentialStore("{}/.lp_creds".format(snap_home)) + credential_store = UnencryptedFileCredentialStore( + "{}/.lp_creds".format(snap_home)) else: - credential_store = UnencryptedFileCredentialStore(os.path.expanduser("~/.lp_creds")) + credential_store = UnencryptedFileCredentialStore( + os.path.expanduser("~/.lp_creds")) lp = Launchpad.login_with( 'foundations', 'production', - version='devel',credential_store=credential_store) + version='devel', credential_store=credential_store) # Connect to the JIRA API try: api = jira_api() - except ValueError as exc: + except ValueError: return "ERROR: Cannot initialize JIRA API." jira = JIRA(api.server, basic_auth=(api.login, api.token)) if opts.sync_project_bugs: - tasks_list = get_all_lp_project_bug_tasks(lp, opts.sync_project_bugs, opts.days) + tasks_list = get_all_lp_project_bug_tasks( + lp, opts.sync_project_bugs, opts.days) if tasks_list is None: return 1 for bug_task in tasks_list: bug = bug_task.bug - lp_to_jira_bug(lp, jira, bug, args[0], opts) + lp_to_jira_bug(lp, jira, bug, opts.project, opts) return 0 - # Make sure there's 2 arguments - if len(args) < 2: - opt_parser.print_usage() - return 1 - - bug_number = args[0] - project_id = args[1] + bug_number = opts.bug + project_id = opts.project bug = get_lp_bug(lp, bug_number) if bug is None: diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..641006c --- /dev/null +++ b/conftest.py @@ -0,0 +1,38 @@ +import pytest + +from unittest.mock import Mock + +@pytest.fixture +def empty_bug(): + bug = Mock() + bug.title = "" + bug.description = "" + bug.id = 0 + bug.tags = [] + bug.web_link = "https://" + bug.bug_tasks = [] + + return bug + +@pytest.fixture +def lp(): + bug = Mock() + bug.title = "test bug" + bug.id = 123456 + bug.bug_tasks = [ + Mock(bug_target_name=n, status=s) + for n, s in zip(['systemd (Ubuntu)', + 'vim (Debian)', + 'glibc (Ubuntu)'], + ['New', + 'Confirmed', + 'New']) + ] + + project1 = Mock() + project1.searchTasks = Mock(return_value=None) + + project2 = Mock() + project2.searchTasks = Mock(return_value=bug) + + return Mock(bugs={123456: bug}, projects={"subiquity": project1, "curtin": project2}) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..b939693 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,45 @@ +[metadata] +name = LpToJira +version = 0.6 +description = A Command Line helper to import launchpad bug in JIRA. +long_description = file: README.md +long_description_content_type = text/markdown +url = https://github.com/canonical/lp-to-jira +project_urls = + Bug Reports = https://github.com/canonical/lp-to-jira/issues + Source Code = https://github.com/mclemenceau/lp-to-jira +classifiers = + Development Status :: 3 - Alpha + License :: OSI Approved :: GNU General Public License v2 (GPLv2) + Operating System :: OS Independent + Programming Language :: Python :: 3 + +[options] +packages = find: +install_requires = + launchpadlib + jira + +[options.extras_require] +test = + pytest + pytest-cov + +[options.entry_points] +console_scripts = + lp-to-jira = LpToJira.lp_to_jira:main + lp-to-jira-report = LpToJira.lp_to_jira_report:main + +[tool:pytest] +addopts = --cov +testpaths = tests + +[coverage:run] +source = LpToJira +branch = true + +[coverage:report] +show_missing = true +exclude_lines = + raise NotImplementedError + assert False diff --git a/setup.py b/setup.py index ee64725..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,35 +1,3 @@ -import setuptools - from setuptools import setup -with open("README.md", "r") as fh: - long_description = fh.read() - -setup( - name="LpToJira", - version="0.6", - author="Matthieu Clemenceau", - author_email="matthieu.clemenceau@canonical.com", - description=("A Command Line helper to import launchpad bug in JIRA."), - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/canonical/lp-to-jira", - project_urls={ - 'Bug Reports': 'https://github.com/canonical/lp-to-jira/issues', - 'Source': 'https://github.com/canonical/lp-to-jira', - }, - packages=setuptools.find_packages(), - keywords='lp to jira', - entry_points={ - 'console_scripts': [ - 'lp-to-jira=LpToJira.lp_to_jira:main', - 'lp-to-jira-report=LpToJira.lp_to_jira_report:main', - ], - }, - install_requires=['jira','launchpadlib'], - classifiers=[ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", - "Operating System :: OS Independent", - ], -) +setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_lp_to_jira.py b/tests/test_lp_to_jira.py new file mode 100644 index 0000000..c1a2438 --- /dev/null +++ b/tests/test_lp_to_jira.py @@ -0,0 +1,140 @@ +import pytest + +from unittest.mock import Mock + +from LpToJira.lp_to_jira import\ + build_jira_issue,\ + create_jira_issue,\ + get_lp_bug,\ + get_lp_bug_pkg,\ + get_all_lp_project_bug_tasks,\ + is_bug_in_jira,\ + lp_to_jira_bug + + +def test_get_lp_bug(lp): + # bad bug id + assert get_lp_bug(lp, 1000000000) == None + # bad launchpad api + assert get_lp_bug(None, 123456) == None + # valid bug + test_bug = get_lp_bug(lp, 123456) + assert test_bug.id == 123456 + assert test_bug.title == "test bug" + + +def test_get_lp_bug_pkg(): + bug = Mock() + bug.bug_tasks = [Mock(bug_target_name='systemd (Ubuntu)')] + + assert get_lp_bug_pkg(bug) == 'systemd' + + bug = Mock() + bug.bug_tasks = [Mock(bug_target_name='systemd (Ubuntu Focal)')] + + assert get_lp_bug_pkg(bug) == 'systemd' + + bug = Mock() + bug.bug_tasks = [Mock(bug_target_name='glibc !@#$)')] + + assert get_lp_bug_pkg(bug) == None + + bug = Mock() + bug.bug_tasks = [Mock(bug_target_name='systemd (Debian)')] + + assert get_lp_bug_pkg(bug) == None + + bug = Mock() + bug.bug_tasks = [ + Mock(bug_target_name=n) + for n in ['systemd (Ubuntu)', 'glibc (Ubuntu)']] + + assert get_lp_bug_pkg(bug) == 'glibc' + + +def test_get_lp_project_bug_tasks(lp): + # Very light testing of what the function could do + # 100% coverage but not necessarly 100% coverage :) + # TODO: test more date's options + # TODO: test various status filters + + assert get_all_lp_project_bug_tasks(lp, "badproject") == None + + # project subiquity exists has no bug + assert get_all_lp_project_bug_tasks(lp, "subiquity") == None + + # project curtin exists and has a bug + assert get_all_lp_project_bug_tasks(lp, "curtin", 5).id == 123456 + + +def test_is_bug_in_jira(): + jira = Mock() + jira.search_issues = Mock(return_value=None) + + bug = Mock() + bug.id = 123 + + assert is_bug_in_jira(jira, bug, "AA") == False + + jira_issue = Mock() + jira_issue = [Mock(key="key")] + + jira.search_issues = Mock(return_value=jira_issue) + jira.client_info = Mock(return_value="jira_client_info") + + assert is_bug_in_jira(jira, bug, "AA") == True + + +def test_build_jira_issue(empty_bug): + # TODO improve coverage to test for non empty bug + default_jira_bug = {'project': '', + 'summary': 'LP#0 [None] ', + 'description': '', 'issuetype': {'name': 'Bug'}, + 'components': [{'name': 'Distro'}]} + + assert build_jira_issue(None, empty_bug, "") == default_jira_bug + + +def test_create_jira_issue(empty_bug, capsys): + jira = Mock() + + jira.create_issue = Mock(return_value=Mock(key="001")) + jira.add_simple_link = Mock(return_value=None) + jira.client_info = Mock(return_value="jira") + + issue_dict = build_jira_issue(None, empty_bug, "") + + jira_issue = create_jira_issue(jira, issue_dict, empty_bug) + + assert "jira/browse/001" in capsys.readouterr().out + + # TODO: Figure out how to make this test succeed, this helps make + # sure the url is created properly + # assert jira.add_simple_link.assert_called_with( + # jira_issue, + # object={'url': 'https://', 'title': 'Launchpad Link'}) + + +def test_lp_to_jira_bug(lp, empty_bug): + jira = Mock() + + jira_issue = [Mock(key="key")] + jira.search_issues = Mock(return_value=jira_issue) + jira.client_info = Mock(return_value="jira") + + lp_to_jira_bug(lp, jira, empty_bug, "AA", ['']) + + jira.search_issues = Mock(return_value=None) + + opts = Mock() + opts.label = "" + lp_to_jira_bug(lp, jira, empty_bug, "AA", opts) + + opts.label = "label" + lp_to_jira_bug(lp, jira, empty_bug, "AA", opts) + + opts.no_lp_tag = False + lp_to_jira_bug(lp, jira, empty_bug, "AA", opts) + + +# ============================================================================= \ No newline at end of file