Skip to content

Commit

Permalink
Merge pull request #33 from melexis/configurable-link-types
Browse files Browse the repository at this point in the history
Validate and add configurability for Jira issue link types; bug fixes
  • Loading branch information
Ben2022 authored Dec 13, 2023
2 parents 4a83831 + 6b642b6 commit 50a7dd3
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 35 deletions.
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

0 comments on commit 50a7dd3

Please sign in to comment.