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

Validate and add configurability for Jira issue link types; bug fixes #33

Merged
merged 11 commits into from
Dec 13, 2023
6 changes: 3 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
'write_to': 'src/mlx/__version__.py'
},
url=PROJECT_URL,
author='Stein Heselmans',
author_email='teh@melexis.com',
description='A python script for extracting data from Jira, and converting to task-juggler (tj3) output',
author='Jasper Craeghs',
author_email='jce@melexis.com',
description='A Python script for extracting data from Jira, and converting to TaskJuggler (tj3) output',
long_description=open("README.rst").read(),
long_description_content_type='text/x-rst',
zip_safe=False,
Expand Down
89 changes: 58 additions & 31 deletions src/mlx/jira_juggler.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from datetime import datetime, time
from functools import cmp_to_key
from getpass import getpass
from itertools import chain
from operator import attrgetter

from dateutil import parser
Expand Down Expand Up @@ -158,6 +159,35 @@ def determine_username(user):
return username


def determine_default_links(link_types_per_name):
default_links = []
for link_types in ({'Blocker': 'inward', 'Blocks': 'inward'}, {'Dependency': 'outward', 'Dependent': 'outward'}):
for link_type_name, direction in link_types.items():
if link_type_name in link_types_per_name:
link = getattr(link_types_per_name[link_type_name], direction)
default_links.append(link)
break
else:
logging.warning("Failed to find any of these default jira-juggler issue link types in your Jira project "
f"configuration: {list(link_types)}. Use --links if you think this is a problem.")
return default_links


def determine_links(jira_link_types, input_links):
valid_links = set()
if input_links is None:
link_types_per_name = {link_type.name: link_type for link_type in jira_link_types}
valid_links = determine_default_links(link_types_per_name)
elif input_links:
unique_input_links = set(input_links)
all_jira_links = chain.from_iterable((link_type.inward, link_type.outward) for link_type in jira_link_types)
missing_links = unique_input_links.difference(all_jira_links)
if missing_links:
logging.warning(f"Failed to find links {missing_links} in your configuration in Jira")
valid_links = unique_input_links - missing_links
return valid_links


class JugglerTaskProperty(ABC):
"""Class for a property of a Task Juggler"""

Expand Down Expand Up @@ -304,7 +334,7 @@ def validate(self, task, tasks):

Args:
task (JugglerTask): Task to which the property belongs
tasks (list): List of JugglerTask instances to which the current task belongs. Will be used to
tasks (list): Modifiable list of JugglerTask instances to which the current task belongs. Will be used to
verify relations to other tasks.
"""
if self.value == 0:
Expand All @@ -321,41 +351,29 @@ class JugglerTaskDepends(JugglerTaskProperty):
DEFAULT_NAME = 'depends'
DEFAULT_VALUE = []
PREFIX = '!'

@property
def value(self):
"""list: Value of the task juggler property"""
return self._value

@value.setter
def value(self, value):
"""Sets value for task juggler property (deep copy)

Args:
value (object): New value of the property
"""
self._value = list(value)
links = set()

def append_value(self, value):
"""Appends value for task juggler property

Args:
value (object): Value to append to the property
"""
self.value.append(value)
if value not in self.value:
self.value.append(value)

def load_from_jira_issue(self, jira_issue):
"""Loads the object with data from a Jira issue

Args:
jira_issue (jira.resources.Issue): The Jira issue to load from
"""
self.value = self.DEFAULT_VALUE
self.value = list(self.DEFAULT_VALUE)
if hasattr(jira_issue.fields, 'issuelinks'):
for link in jira_issue.fields.issuelinks:
if hasattr(link, 'inwardIssue') and link.type.name == 'Blocker':
if hasattr(link, 'inwardIssue') and link.type.inward in self.links:
self.append_value(to_identifier(link.inwardIssue.key))
if hasattr(link, 'outwardIssue') and link.type.name in ('Dependency', 'Dependent'):
elif hasattr(link, 'outwardIssue') and link.type.outward in self.links:
self.append_value(to_identifier(link.outwardIssue.key))

def validate(self, task, tasks):
Expand All @@ -366,8 +384,9 @@ def validate(self, task, tasks):
tasks (list): List of JugglerTask instances to which the current task belongs. Will be used to
verify relations to other tasks.
"""
for val in self.value:
if val not in [to_identifier(tsk.key) for tsk in tasks]:
task_ids = [to_identifier(tsk.key) for tsk in tasks]
for val in list(self.value):
if val not in task_ids:
logging.warning('Removing link to %s for %s, as not within scope', val, task.key)
self.value.remove(val)

Expand Down Expand Up @@ -457,18 +476,17 @@ def load_from_jira_issue(self, jira_issue):
self.properties['depends'] = JugglerTaskDepends(jira_issue)
self.properties['time'] = JugglerTaskTime()

def validate(self, tasks):
def validate(self, tasks, property_identifier):
"""Validates (and corrects) the current task

Args:
tasks (list): List of JugglerTask instances to which the current task belongs. Will be used to
verify relations to other tasks.
property_identifier (str): Identifier of property type
"""
if self.key == self.DEFAULT_KEY:
logging.error('Found a task which is not initialized')

for task_property in self.properties.values():
task_property.validate(self, tasks)
self.properties[property_identifier].validate(self, tasks)

def __str__(self):
"""Converts the JugglerTask to the task juggler syntax
Expand Down Expand Up @@ -525,14 +543,15 @@ def determine_resolved_at_date(self):
class JiraJuggler:
"""Class for task-juggling Jira results"""

def __init__(self, endpoint, user, token, query):
def __init__(self, endpoint, user, token, query, links=None):
"""Constructs a JIRA juggler object

Args:
endpoint (str): Endpoint for the Jira Cloud (or Server)
user (str): Email address (or username)
token (str): API token (or password)
query (str): The query to run
links (set/None): List of issue link type inward/outward links; None to use the default configuration
"""
global id_to_username_mapping
id_to_username_mapping = {}
Expand All @@ -544,15 +563,19 @@ def __init__(self, endpoint, user, token, query):
self.query = query
self.issue_count = 0

all_jira_link_types = jirahandle.issue_link_types()
JugglerTaskDepends.links = determine_links(all_jira_link_types, links)

@staticmethod
def validate_tasks(tasks):
"""Validates (and corrects) tasks

Args:
tasks (list): List of JugglerTask instances to validate
"""
for task in list(tasks):
task.validate(tasks)
for property_identifier in ('allocate', 'effort', 'depends', 'time'):
for task in list(tasks):
task.validate(tasks, property_identifier)

def load_issues_from_jira(self, depend_on_preceding=False, sprint_field_name='', **kwargs):
"""Loads issues from Jira
Expand Down Expand Up @@ -634,7 +657,7 @@ def link_to_preceding_task(tasks, weeklymax=5.0, current_date=datetime.now()):
time_property = task.properties['time']

if task.is_resolved:
depends_property.clear() # don't output any links in JIRA
depends_property.clear() # don't output any links from JIRA
time_property.name = 'end'
time_property.value = task.resolved_at_repr
else:
Expand Down Expand Up @@ -783,6 +806,11 @@ def main():
help='Query to perform on JIRA server')
argpar.add_argument('-o', '--output', default=DEFAULT_OUTPUT,
help='Output .tjp file for task-juggler')
argpar.add_argument('-L', '--links', nargs='*',
help="Specific issue link type inward/outward links to consider for TaskJuggler's 'depends' "
"keyword, e.g. 'depends on'. "
"By default, link types Dependency/Dependent (outward only) and Blocker/Blocks (inwardy only) "
"are considered. Specify an empty value to ignore Jira issue links altogether.")
argpar.add_argument('-D', '--depend-on-preceding', action='store_true',
help='Flag to let tasks depend on the preceding task with the same assignee')
argpar.add_argument('-s', '--sort-on-sprint', dest='sprint_field_name', default='',
Expand All @@ -795,12 +823,11 @@ def main():
help='Specify the offset-naive date to use for calculation as current date. If no value is '
'specified, the current value of the system clock is used.')
args = argpar.parse_args()

set_logging_level(args.loglevel)

user, token = fetch_credentials()
endpoint = config('JIRA_API_ENDPOINT', default=DEFAULT_JIRA_URL)
JUGGLER = JiraJuggler(endpoint, user, token, args.query)
JUGGLER = JiraJuggler(endpoint, user, token, args.query, links=args.links)

JUGGLER.juggle(
output=args.output,
Expand Down
38 changes: 37 additions & 1 deletion tests/test_jira_juggler.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from dateutil import parser
from parameterized import parameterized
from collections import namedtuple

import unittest

Expand All @@ -33,6 +34,32 @@
from jira import JIRA


LinkType = namedtuple('LinkType', 'id name inward outward self')
ISSUE_LINK_TYPES = [
LinkType(
id="1000",
name="Duplicate",
inward="is duplicated by",
outward="duplicates",
self="http://www.example.com/jira/rest/api/2//issueLinkType/1000",
),
LinkType(
id="1010",
name="Blocker",
inward="is blocked by",
outward="blocks",
self="http://www.example.com/jira/rest/api/2//issueLinkType/1010",
),
LinkType(
id="1050",
name="Dependency",
inward="is dependency of",
outward="depends on",
self="http://www.example.com/jira/rest/api/2//issueLinkType/1050",
),
]


class TestJiraJuggler(unittest.TestCase):
'''
Testing JiraJuggler interface
Expand Down Expand Up @@ -112,7 +139,10 @@ class TestJiraJuggler(unittest.TestCase):
"key": "{depends}"
}},
"type": {{
"name": "Blocker"
"name": "Blocker",
"id": "1010",
"inward": "is blocked by",
"outward": "blocks"
}}
}}
'''
Expand Down Expand Up @@ -271,6 +301,7 @@ def test_broken_depends(self, jira_mock):
'''Test for removing a broken link to a dependant task'''
jira_mock_object = MagicMock(spec=JIRA)
jira_mock.return_value = jira_mock_object
jira_mock_object.issue_link_types.return_value = ISSUE_LINK_TYPES
juggler = dut.JiraJuggler(self.URL, self.USER, self.PASSWD, self.QUERY)
self.assertEqual(self.QUERY, juggler.query)

Expand All @@ -291,6 +322,7 @@ def test_task_depends(self, jira_mock):
'''Test for dual happy flow: one task depends on the other'''
jira_mock_object = MagicMock(spec=JIRA)
jira_mock.return_value = jira_mock_object
jira_mock_object.issue_link_types.return_value = ISSUE_LINK_TYPES
juggler = dut.JiraJuggler(self.URL, self.USER, self.PASSWD, self.QUERY)
self.assertEqual(self.QUERY, juggler.query)

Expand Down Expand Up @@ -325,6 +357,7 @@ def test_task_double_depends(self, jira_mock):
'''Test for extended happy flow: one task depends on two others'''
jira_mock_object = MagicMock(spec=JIRA)
jira_mock.return_value = jira_mock_object
jira_mock_object.issue_link_types.return_value = ISSUE_LINK_TYPES
juggler = dut.JiraJuggler(self.URL, self.USER, self.PASSWD, self.QUERY)
self.assertEqual(self.QUERY, juggler.query)

Expand Down Expand Up @@ -370,6 +403,7 @@ def test_resolved_task(self, jira_mock):
Test that the most recent transition to the Approved/Resolved state is used to mark the end'''
jira_mock_object = MagicMock(spec=JIRA)
jira_mock.return_value = jira_mock_object
jira_mock_object.issue_link_types.return_value = ISSUE_LINK_TYPES
juggler = dut.JiraJuggler(self.URL, self.USER, self.PASSWD, self.QUERY)
histories = [
{
Expand Down Expand Up @@ -439,6 +473,7 @@ def test_closed_task(self, jira_mock):
'''
jira_mock_object = MagicMock(spec=JIRA)
jira_mock.return_value = jira_mock_object
jira_mock_object.issue_link_types.return_value = ISSUE_LINK_TYPES
juggler = dut.JiraJuggler(self.URL, self.USER, self.PASSWD, self.QUERY)
histories = [
{
Expand Down Expand Up @@ -476,6 +511,7 @@ def test_depend_on_preceding(self, jira_mock):
'''Test --depends-on-preceding, --weeklymax and --current-date options'''
jira_mock_object = MagicMock(spec=JIRA)
jira_mock.return_value = jira_mock_object
jira_mock_object.issue_link_types.return_value = ISSUE_LINK_TYPES
juggler = dut.JiraJuggler(self.URL, self.USER, self.PASSWD, self.QUERY)
histories = [
{
Expand Down