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

Redmine improvements #395

Merged
merged 17 commits into from
Jan 29, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions bugwarrior/docs/services/redmine.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ Here's an example of a Redmine target::
redmine.key = c0c4c014cafebabe
redmine.user_id = 7
redmine.project_name = redmine
redmine.issue_limit = 100

The above example is the minimum required to import issues from
Redmine. You can also feel free to use any of the
configuration options described in :ref:`common_configuration_options`.
You can also feel free to use any of the configuration options described in
:ref:`common_configuration_options`.

There are also `redmine.login`/`redmine.password` settings if your
instance is behind basic auth.
Expand Down
153 changes: 136 additions & 17 deletions bugwarrior/services/redmine.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import six
import requests
import re

from bugwarrior.config import die
from bugwarrior.services import Issue, IssueService, ServiceClient
from taskw import TaskWarriorShellout

import logging
log = logging.getLogger(__name__)


class RedMineClient(ServiceClient):
def __init__(self, url, key, auth):
def __init__(self, url, key, auth, issue_limit):
self.url = url
self.key = key
self.auth = auth
self.issue_limit = issue_limit

def find_issues(self, user_id=None):
args = {"limit": 100}
if user_id is not None:
args["assigned_to_id"] = user_id
def find_issues(self, issue_limit=100, only_if_assigned=False):
args = {}
# TODO: if issue_limit is greater than 100, implement pagination to return all issues.
# Leave the implementation of this to the unlucky soul with >100 issues assigned to them.
if issue_limit is not None:
args["limit"] = issue_limit

if only_if_assigned:
args["assigned_to_id"] = 'me'
return self.call_api("/issues.json", args)["issues"]

def call_api(self, uri, params):
Expand All @@ -36,6 +44,18 @@ class RedMineIssue(Issue):
URL = 'redmineurl'
SUBJECT = 'redminesubject'
ID = 'redmineid'
DESCRIPTION = 'redminedescription'
TRACKER = 'redminetracker'
STATUS = 'redminestatus'
AUTHOR = 'redmineauthor'
CATEGORY = 'redminecategory'
START_DATE = 'redminestartdate'
SPENT_HOURS = 'redminespenthours'
ESTIMATED_HOURS = 'redmineestimatedhours'
CREATED_ON = 'redminecreatedon'
UPDATED_ON = 'redmineupdatedon'
DUEDATE = 'redmineduedate'
ASSIGNED_TO = 'redmineassignedto'

UDAS = {
URL: {
Expand All @@ -47,11 +67,60 @@ class RedMineIssue(Issue):
'label': 'Redmine Subject',
},
ID: {
'type': 'string',
'type': 'numeric',
'label': 'Redmine ID',
},
DESCRIPTION: {
'type': 'string',
'label': 'Redmine Description',
},
TRACKER: {
'type': 'string',
'label': 'Redmine Tracker',
},
STATUS: {
'type': 'string',
'label': 'Redmine Status',
},
AUTHOR: {
'type': 'string',
'label': 'Redmine Author',
},
CATEGORY: {
'type': 'string',
'label': 'Redmine Category',
},
START_DATE: {
'type': 'date',
'label': 'Redmine Start Date',
},
SPENT_HOURS: {
'type': 'duration',
'label': 'Redmine Spent Hours',
},
ESTIMATED_HOURS: {
'type': 'duration',
'label': 'Redmine Estimated Hours',
},
CREATED_ON: {
'type': 'date',
'label': 'Redmine Created On',
},
UPDATED_ON: {
'type': 'date',
'label': 'Redmine Updated On',
},
DUEDATE: {
'type': 'date',
'label': 'Redmine Due Date'
},
ASSIGNED_TO: {
'type': 'string',
'label': 'Redmine Assigned To',
},

}
UNIQUE_KEY = (URL, )
UNIQUE_KEY = (ID, )

PRIORITY_MAP = {
'Low': 'L',
Expand All @@ -62,13 +131,53 @@ class RedMineIssue(Issue):
}

def to_taskwarrior(self):
due_date = self.record.get('due_date')
start_date = self.record.get('start_date')
updated_on = self.record.get('updated_on')
created_on = self.record.get('created_on')
spent_hours = self.record.get('spent_hours')
estimated_hours = self.record.get('estimated_hours')
category = self.record.get('category')
assigned_to = self.record.get('assigned_to')

if due_date:
due_date = self.parse_date(due_date).replace(microsecond=0)
if start_date:
start_date = self.parse_date(start_date).replace(microsecond=0)
if updated_on:
updated_on = self.parse_date(updated_on).replace(microsecond=0)
if created_on:
created_on = self.parse_date(created_on).replace(microsecond=0)
if spent_hours:
spent_hours = str(spent_hours) + ' hours'
spent_hours = self.get_converted_hours(spent_hours)
if estimated_hours:
estimated_hours = str(estimated_hours) + ' hours'
estimated_hours = self.get_converted_hours(estimated_hours)
if category:
category = category['name']
if assigned_to:
assigned_to = assigned_to['name']

return {
'project': self.get_project_name(),
'annotations': self.extra.get('annotations', []),
'priority': self.get_priority(),

self.URL: self.get_issue_url(),
self.SUBJECT: self.record['subject'],
self.ID: self.record['id']
self.ID: self.record['id'],
self.DESCRIPTION: self.record['description'],
self.TRACKER: self.record['tracker']['name'],
self.STATUS: self.record['status']['name'],
self.AUTHOR: self.record['author']['name'],
self.ASSIGNED_TO: assigned_to,
self.CATEGORY: category,
self.START_DATE: start_date,
self.CREATED_ON: created_on,
self.UPDATED_ON: updated_on,
self.DUEDATE: due_date,
self.ESTIMATED_HOURS: estimated_hours,
self.SPENT_HOURS: spent_hours,
}

def get_priority(self):
Expand All @@ -82,10 +191,21 @@ def get_issue_url(self):
self.origin['url'] + "/issues/" + six.text_type(self.record["id"])
)

def get_converted_hours(self, estimated_hours):
tw = TaskWarriorShellout()
calc = tw._execute('calc', estimated_hours)
return (
calc[0].rstrip()
)
Copy link
Collaborator

Choose a reason for hiding this comment

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

None of the other services convert to taskwarrior format so I'm doubtful we should be here. You want to use the redmine duedate as your taskwarrior duedate though, and I'm not sure how, or if it's possible, to do this if the due dates are stored in integer format.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The estimated_hours field is different from due date. Estimated hours comes from Redmine in the format 2, 2.9, 3.25, etc. Here I am converting this integer value to a taskwarrior duration field.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Wow, I have no idea what I was thinking an hour ago.


def get_project_name(self):
if self.origin['project_name']:
return self.origin['project_name']
return self.record["project"]["name"]
# TODO: It would be nice to use the project slug (if the Redmine
# instance supports it), but this would require (1) an API call
# to get the list of projects, and then a look up between the
# project ID contained in self.record and the list of projects.
return re.sub(r'[^a-zA-Z0-9]', '', self.record["project"]["name"]).lower()

def get_default_description(self):
return self.build_default_description(
Expand All @@ -105,13 +225,13 @@ def __init__(self, *args, **kw):

self.url = self.config_get('url').rstrip("/")
self.key = self.config_get('key')
self.user_id = self.config_get('user_id')
self.issue_limit = self.config_get('issue_limit')

login = self.config_get_default('login')
if login:
password = self.config_get_password('password', login)
auth = (login, password) if (login and password) else None
self.client = RedMineClient(self.url, self.key, auth)
self.client = RedMineClient(self.url, self.key, auth, self.issue_limit)

self.project_name = self.config_get_default('project_name')

Expand All @@ -125,20 +245,19 @@ def get_service_metadata(self):
def get_keyring_service(cls, config, section):
url = config.get(section, cls._get_key('url'))
login = config.get(section, cls._get_key('login'))
user_id = config.get(section, cls._get_key('user_id'))
return "redmine://%s@%s/%s" % (login, url, user_id)
return "redmine://%s@%s/%s" % (login, url)

@classmethod
def validate_config(cls, config, target):
for k in ('redmine.url', 'redmine.key', 'redmine.user_id'):
for k in ('redmine.url', 'redmine.key'):
if not config.has_option(target, k):
die("[%s] has no '%s'" % (target, k))

IssueService.validate_config(config, target)

def issues(self):
issues = self.client.find_issues(self.user_id)
only_if_assigned = self.config_get_default('only_if_assigned', False)
issues = self.client.find_issues(self.issue_limit, only_if_assigned)
log.debug(" Found %i total.", len(issues))

for issue in issues:
yield self.get_issue_for_record(issue)
50 changes: 42 additions & 8 deletions tests/test_redmine.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from builtins import next
import datetime

import mock
import dateutil
import responses

from bugwarrior.services.redmine import RedMineService
Expand All @@ -8,11 +11,16 @@


class TestRedmineIssue(AbstractServiceTest, ServiceTest):
maxDiff = None
SERVICE_CONFIG = {
'redmine.url': 'https://something',
'redmine.key': 'something_else',
'redmine.user_id': '10834u0234',
'redmine.issue_limit': '100',
}
arbitrary_created = datetime.datetime.utcnow().replace(
tzinfo=dateutil.tz.tz.tzutc(), microsecond=0) - datetime.timedelta(1)
arbitrary_updated = datetime.datetime.utcnow().replace(
tzinfo=dateutil.tz.tz.tzutc(), microsecond=0)
arbitrary_issue = {
"assigned_to": {
"id": 35546,
Expand All @@ -22,7 +30,8 @@ class TestRedmineIssue(AbstractServiceTest, ServiceTest):
"id": 35546,
"name": "Adam Coddington"
},
"created_on": "2014-11-19T16:40:29Z",
"created_on": arbitrary_created.isoformat(),
"due_on": "2016-12-30T16:40:29Z",
"description": "This is a test issue.",
"done_ratio": 0,
"id": 363901,
Expand All @@ -32,7 +41,7 @@ class TestRedmineIssue(AbstractServiceTest, ServiceTest):
},
"project": {
"id": 27375,
"name": "Bugwarrior"
"name": "Boiled Cabbage - Yum"
},
"status": {
"id": 1,
Expand All @@ -43,7 +52,7 @@ class TestRedmineIssue(AbstractServiceTest, ServiceTest):
"id": 4,
"name": "Task"
},
"updated_on": "2014-11-19T16:40:29Z"
"updated_on": arbitrary_updated.isoformat(),
}

def setUp(self):
Expand All @@ -56,12 +65,24 @@ def test_to_taskwarrior(self):
issue = self.service.get_issue_for_record(self.arbitrary_issue)

expected_output = {
'project': self.arbitrary_issue['project']['name'],
'annotations': [],
'project': issue.get_project_name(),
'priority': self.service.default_priority,

issue.DUEDATE: None,
issue.ASSIGNED_TO: self.arbitrary_issue['assigned_to']['name'],
issue.AUTHOR: self.arbitrary_issue['author']['name'],
issue.CATEGORY: None,
issue.DESCRIPTION: self.arbitrary_issue['description'],
issue.ESTIMATED_HOURS: None,
issue.STATUS: 'New',
issue.URL: arbitrary_url,
issue.SUBJECT: self.arbitrary_issue['subject'],
issue.TRACKER: u'Task',
issue.CREATED_ON: self.arbitrary_created,
issue.UPDATED_ON: self.arbitrary_updated,
issue.ID: self.arbitrary_issue['id'],
issue.SPENT_HOURS: None,
issue.START_DATE: None,
}

def get_url(*args):
Expand All @@ -75,18 +96,31 @@ def get_url(*args):
@responses.activate
def test_issues(self):
self.add_response(
'https://something/issues.json?assigned_to_id=10834u0234&limit=100',
'https://something/issues.json?limit=100',
json={'issues': [self.arbitrary_issue]})

issue = next(self.service.issues())

expected = {
'annotations': [],
issue.DUEDATE: None,
'description':
u'(bw)Is#363901 - Biscuits .. https://something/issues/363901',
'priority': 'M',
'project': u'Bugwarrior',
'project': u'boiledcabbageyum',
'redmineid': 363901,
issue.SPENT_HOURS: None,
issue.START_DATE: None,
'redmineassignedto': 'Adam Coddington',
'redmineauthor': 'Adam Coddington',
issue.CATEGORY: None,
issue.DESCRIPTION: self.arbitrary_issue['description'],
issue.ESTIMATED_HOURS: None,
issue.STATUS: 'New',
'redminesubject': u'Biscuits',
'redminetracker': u'Task',
issue.CREATED_ON: self.arbitrary_created,
issue.UPDATED_ON: self.arbitrary_updated,
'redmineurl': u'https://something/issues/363901',
'tags': []}

Expand Down