Skip to content

Commit

Permalink
Create new point release (#86)
Browse files Browse the repository at this point in the history
  • Loading branch information
Michael Krause authored Jun 26, 2019
1 parent 09c59f7 commit 263123a
Show file tree
Hide file tree
Showing 8 changed files with 535 additions and 44 deletions.
2 changes: 2 additions & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
dist: xenial # required for Python >= 3.7
language: python

python:
- "3.6"
- "3.7"

install:
- pip install -r requirements.txt
Expand Down
39 changes: 32 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,23 @@
# sync-agile-boards
This is a synchronization tool for Jira and ZenHub that is designed to complement Unito.
Unito can only synchronize ZenHub information that is also stored in GitHub. sync-agile-boards
synchronizes issue point values, pipeline/status, epic status and membership, and sprint/milestone
status, which are not handled by Unito. It is assumed that Unito is being used to keep the other repository
data in sync: each issue should have information in the description added by Unito that links it to
its match.
Synchronizes issues between Jira and ZenHub. It is designed to complement Unito.
Unito can only synchronize ZenHub information that is also stored in GitHub. _sync-agile-boards_
synchronizes the following attributes (which are not handled by Unito):

* issue point values
* pipeline/status
* epic status and membership
* sprint (Jira) and milestone (ZenHub) membership

Synchronizing sprint information depends on the existence of a sprint with identical names in both management
systems. The user needs to verify that a sprint found in the source management system exists in the destination
management system with the identical name before invoking _sync-agile-boards_. Sprint synchronization is not mirrored.
That means, if the source management system contains a
sprint with name _sprint1_ but the destination management does not contain a sprint with that name, an issue
associated with a _sprint1_ will be processed but its sprint information will remain unchanged (a warning will
be logged).

It is assumed that Unito is being used to keep the other repository data synchronized: each issue should have
information in the description added by Unito that links it to its match.

## Configuration

Expand All @@ -29,6 +42,18 @@ print(encoded_token)
```
Write `encoded_token` to `~/.sync-agile-board-jira_config`.

## Set-up

_sync-agile-boards_ requires Python 3, `pip` and `virtualenv` installed. We tested it on Python 3.6 and 3.7.
To install dependencies run the following in a terminal from the project root:
```bash
virtualenv --python=python3.6 .venv
source .venv/bin/activate
pip install -r requirements.txt
```
This installs a virtual environment that contains all requirements to run `sync_agile_boards.py`. To exit the virtual
environment execute `deactivate`.

## Mapping of pipeline labels

Pipelines can be semantically identical between the two management systems but they may use different labels.
Expand Down Expand Up @@ -159,7 +184,7 @@ Each string is formatted exactly as if you entered it in the command line as des

## Tests

To run all tests execute
To run all tests activate the virtual environment as described in _Set-up_ above and execute
```bash
python -m unittest discover -s tests
```
Expand Down
2 changes: 1 addition & 1 deletion src/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ def __init__(self, key: str, repo: 'GitHubRepo', content: dict = None):

if content['milestone']:
self.milestone_name = content['milestone']['title']
self.milestone_number = content['milestone']['number']
self.milestone_id = content['milestone']['number']

# TODO: Note that GitHub api responses have both dict 'assignee' and dict array 'assignees' fields. 'assignee'
# is deprecated. This could cause problems if multiple people are assigned to an issue in GitHub, because the
Expand Down
10 changes: 4 additions & 6 deletions src/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,9 @@ def __init__(self):
self.updated = None # datetime object

self.sprint_name = None # str, when synchronized this should be the same in Jira and ZenHub
self.milestone_name = None # str
self.milestone_number = None # int, unique to GitHub/ZenHub
self.sprint_name = None
self.sprint_id = None # str, unique to Jira

self.milestone_name = None # str
self.milestone_id = None # int, unique to GitHub/ZenHub
self.repo = None # Repo object, the repo in which this issue lives

def update_from(self, source: 'Issue'):
Expand Down Expand Up @@ -97,5 +95,5 @@ def api_call(self, action, url_tail: str, url_head: str = None, json: dict = Non
success_code=success_code)['items'])
return content

else:
raise RuntimeError(f'{response.status_code} Error: {response.text}')
elif response.json(): # we don't want to raise an error, but deal with it locally
return response.json()
12 changes: 7 additions & 5 deletions src/jira.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ def remove_from_sprint(self):
"""Remove this issue from any sprint it may be in"""

logger.debug(f'Removing Jira issue {self.jira_key} from sprint {self.sprint_name}')
self.sprint_name = None
self.sprint_id = None
self.repo.api_call(requests.put, f'issue/{self.jira_key}',
json={'fields': {CustomFieldNames.sprint: None}}, success_code=204)

Expand All @@ -194,16 +196,16 @@ def get_sprint_id(self, sprint_title: str) -> int or None:
:param sprint_title: Jira sprint name to look up ID for
"""
url = f'search?jql=sprint="{sprint_title}"'
content = self.repo.api_call(requests.get, url)
try:
content = self.repo.api_call(requests.get, url)
data = content['issues'][0]['fields']['customfield_10010']
# The following attempts to extract the sprint ID from a string wrapped in a list, which contains one "["
# character. It is very cryptic. Please see test in for Sync class for an example of "data".
sprint_info = data[0].split('[')[1].split(',')
jira_sprint_id = int(re.search(r'\d+', sprint_info[0]).group(0))

logger.info(f'Sync sprint: Found sprint ID for sprint {sprint_title}')
return jira_sprint_id
except KeyError:
logger.warning(first(content['errorMessages']))
jira_sprint_id = None

except RuntimeError:
return None
return jira_sprint_id
37 changes: 35 additions & 2 deletions src/sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ def sync_epics(source: 'Issue', dest: 'Issue'):
def sync_sprints(source: 'Issue', dest: 'Issue'):
"""
Sync sprint membership of two issues
:param source: This issue's sprint status will be replicated in the sink issue
:param source: This issue's sprint status will be replicated in the dest issue
:param dest: This issue's sprint status will be updated to match the source
"""

Expand All @@ -180,6 +180,23 @@ def sync_sprints(source: 'Issue', dest: 'Issue'):
else:
logger.warning(
f'Sync sprint: No Sprint ID found for {dest.jira_key} and sprint title {milestone_title}')
else:
assert dest.sprint_name != source.milestone_name
# Check if any sprint in dest has same name as source.milestone_name. If so, swap.
sprint_id = dest.get_sprint_id(source.milestone_name)
if sprint_id is not None:
logger.info(f'Sync sprint: Found sprint name {source.milestone_name} in Jira project')
dest.remove_from_sprint() # old sprint
dest.sprint_name = source.milestone_name
dest.sprint_id = sprint_id
logger.info(f'Jira issue {dest.jira_key} now part of sprint {dest.sprint_name}')
dest.add_to_sprint(sprint_id) # new sprint
else:
logger.warning(
f'Sync sprint: Cannot find sprint name {source.milestone_name} in the destination. '
f'Association of issue {dest.jira_key} will remain unchanged. Sprint name {dest.sprint_name}'
f' does not match milestone name {source.milestone_name} - sprint and milestone names '
f'must match!')

elif source.__class__.__name__ == 'JiraIssue':
if source.sprint_name is None:
Expand All @@ -201,4 +218,20 @@ def sync_sprints(source: 'Issue', dest: 'Issue'):
else:
logger.warning(
f'Sync sprint: No Sprint ID found for {dest.github_key} and sprint title {sprint_title}')

else:
assert dest.milestone_name != source.sprint_name
# Logic same as in above leg of conditional.
milestone_id = dest.get_milestone_id(source.sprint_name)
if milestone_id:
logger.info(f'Sync sprint: Found sprint name {source.sprint_name} in GitHub repo')
dest.remove_from_milestone()
dest.milestone_name = source.sprint_name
dest.milestone_id = milestone_id
logger.info(f'GitHub issue {dest.github_key} now part of milestone {dest.milestone_name}')
dest.add_to_milestone(milestone_id)
else:
logger.warning(
f'Sync sprint: Cannot find sprint name {source.sprint_name} in the destination. '
f'Association of issue {dest.milestone_id} will remain unchanged. Milestone name '
f'{dest.milestone_name} does not match sprint name {source.sprint_name} - sprint and '
f'milestone names must match!')
33 changes: 29 additions & 4 deletions tests/test_jira.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import datetime
import pytz
import unittest
from unittest.mock import patch

Expand All @@ -10,9 +9,9 @@ def mocked_response(*args, **kwargs):
"""A class to mock a response from a Jira API call"""

class MockResponse:
def __init__(self, json_data):
def __init__(self, json_data, status_code=200):
self.json_data = json_data
self.status_code = 200
self.status_code = status_code

def json(self):
return self.json_data
Expand Down Expand Up @@ -85,6 +84,21 @@ def json(self):
}
)

elif args == ('https://mock-org.atlassian.net/search?jql=sprint="testsprint1"',):
return MockResponse(
{'issues': [{'fields': {'customfield_10010': [
'com.atlassian.greenhopper.service.sprint.Sprint@447ac53[id=65,rapidViewId=82,state=ACTIVE,name=testsprint1,goal=,startDate=2019-04-25T21:51:28.028Z,endDate=2019-05-31T21:51:00.000Z,completeDate=<null>,sequence=65]']}}]},
status_code=200
)

elif args == ('https://mock-org.atlassian.net/search?jql=sprint="doesNotExist"',):
return MockResponse(
{'errorMessages':
["Sprint with name 'doesNotExist' does not exist or you do not have permission to view it."],
'warningMessages': []},
status_code=400
)

else:
raise RuntimeError(args, kwargs)

Expand All @@ -101,7 +115,6 @@ def setUp(cls, get_mocked_response, get_mocked_token):

# Initialize a board with all its issues
cls.board = JiraRepo(repo_name='TEST', jira_org='org')
print(cls.board.issues)
cls.j = cls.board.issues['REAL-ISSUE-1']
cls.k = cls.board.issues['REAL-ISSUE-2']

Expand Down Expand Up @@ -133,3 +146,15 @@ def test_update_from(self):
# self.assertEqual(self.k.assignees, ['aaaaa'])
self.assertEqual(self.k.story_points, 7.0)
self.assertEqual(self.k.status, 'Done')

@patch('src.jira.requests.get', side_effect=mocked_response)
def test_get_sprint_id(self, jira_get):

id = self.j.get_sprint_id(sprint_title='testsprint1')
self.assertEqual(65, id)

id = self.j.get_sprint_id(sprint_title='doesNotExist')
expected = 'https://mock-org.atlassian.net/search?jql=sprint="doesNotExist"'
observed = jira_get.call_args[0][0]
self.assertEqual(expected, observed)
self.assertEqual(id, None)
Loading

0 comments on commit 263123a

Please sign in to comment.