diff --git a/.travis.yml b/.travis.yml index 0c3168d8d..19b864421 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,7 +38,7 @@ matrix: env: JIRAVERSION=1.0.10 install: - - pip install .[jira,megaplan,activecollab,bts,bugzilla,trac,gmail] + - pip install .[jira,megaplan,activecollab,bts,bugzilla,trac,gmail,kanboard] - pip install codecov - pip install coverage script: nosetests -w tests --with-coverage --cover-branches --cover-package=bugwarrior -v diff --git a/bugwarrior/README.rst b/bugwarrior/README.rst index b763df1f8..cff4e1794 100644 --- a/bugwarrior/README.rst +++ b/bugwarrior/README.rst @@ -16,6 +16,7 @@ It currently supports the following remote resources: - `gitlab `_ (api v3) - `gmail `_ - `jira `_ + - `kanboard `_ - `megaplan `_ - `pagure `_ - `phabricator `_ @@ -72,3 +73,4 @@ Contributors - BinaryBabel (contributed support for YouTrack) - Matthew Cengia (contributed extra support for Trello) - Andrew Demas (contributed support for PivotalTracker) +- Florian Preinstorfer (contributed support for Kanboard) diff --git a/bugwarrior/docs/configuration.rst b/bugwarrior/docs/configuration.rst index 550200643..d95c8a1fc 100644 --- a/bugwarrior/docs/configuration.rst +++ b/bugwarrior/docs/configuration.rst @@ -148,6 +148,21 @@ Example Configuration jira.version = 5 jira.add_tags = enterprisey,work + + # This is a kanboard example. + [my_kanboard] + service = kanboard + kanboard.url = https://kanboard.example.org + kanboard.username = ralphbean + + # Your password or, even better, API token + kanboard.password = my_api_token + + # A custom query to search for open issues. By default, assigned and open + # tasks are queried. + kanboard.query = status:open assignee:me + + # Here's an example of a phabricator target [my_phabricator] service = phabricator diff --git a/bugwarrior/docs/getting.rst b/bugwarrior/docs/getting.rst index 68b2fba1a..8ce54f77f 100644 --- a/bugwarrior/docs/getting.rst +++ b/bugwarrior/docs/getting.rst @@ -32,7 +32,7 @@ Installing from https://pypi.python.org/pypi/bugwarrior is easy with By default, ``bugwarrior`` will be installed with support for the following services: Bitbucket, Github, Gitlab, Pagure, Phabricator, Redmine, Teamlab, and -Versionone. There is optional support for Jira, Megaplan.ru, Active Collab, +Versionone. There is optional support for Jira, Kanboard, Megaplan.ru, Active Collab, Debian BTS, Trac, Bugzilla, and but those require extra dependencies that are installed by specifying ``bugwarrior[service]`` in the commands above. For example, if you want to use bugwarrior with Jira:: @@ -43,6 +43,7 @@ The following extra dependency sets are available: - keyring (See also `linux installation instructions `_.) - jira +- kanboard - megaplan - activecollab - bts diff --git a/bugwarrior/docs/services/kanboard.rst b/bugwarrior/docs/services/kanboard.rst new file mode 100644 index 000000000..e320d9076 --- /dev/null +++ b/bugwarrior/docs/services/kanboard.rst @@ -0,0 +1,61 @@ +Kanboard +======== + +You can import tasks from your Kanboard instance using the ``kanboard`` service name. + +Additional Requirements +----------------------- + +Install the following package using ``pip``: + +* ``kanboard`` + +Example Service +--------------- + +Here's an example of a Kanboard project:: + + [my_issue_tracker] + service = kanboard + kanboard.url = https://kanboard.example.org + kanboard.username = ralph + kanboard.password = my_api_token + +The above example is the minimum required to import issues from Kanboard. It is +recommended to use a personal API token instead of a password, which can be +created on the Kanboard settings page. You can also feel free to use any of the +configuration options described in `Service Features`_ below. + +Service Features +---------------- + +Specify the Query to Use for Gathering Issues ++++++++++++++++++++++++++++++++++++++++++++++ + +By default, all open issues assigned to the specified username are imported. +One may use the `kanboard.query` parameter to modify the search query. + +For example, to import all open issues assigned to 'frank', use the following +configuration option:: + + kanboard.query = status:open assignee:frank + + +Provided UDA Fields +------------------- + ++-----------------------------+---------------------+---------------------+ +| Field Name | Description | Type | ++=============================+=====================+=====================+ +| ``kanboardtaskid`` | Task ID | Number (numeric) | ++-----------------------------+---------------------+---------------------+ +| ``kanboardtasktitle`` | Task Title | Text (string) | ++-----------------------------+---------------------+---------------------+ +| ``kanboardtaskdescription`` | Task Description | Text (string) | ++-----------------------------+---------------------+---------------------+ +| ``kanboardprojectid`` | Project ID | Number (numeric) | ++-----------------------------+---------------------+---------------------+ +| ``kanboardprojectname`` | Project Name | Text (string) | ++-----------------------------+---------------------+---------------------+ +| ``kanboardurl`` | URL | Text (string) | ++-----------------------------+---------------------+---------------------+ diff --git a/bugwarrior/services/kanboard.py b/bugwarrior/services/kanboard.py new file mode 100644 index 000000000..62c923d8c --- /dev/null +++ b/bugwarrior/services/kanboard.py @@ -0,0 +1,169 @@ +import datetime +import logging +import re +from urllib.parse import urlparse + +from dateutil.tz.tz import tzutc +from kanboard import Client + +from bugwarrior.config import die +from bugwarrior.services import Issue, IssueService + +log = logging.getLogger(__name__) + + +class KanboardIssue(Issue): + TASK_ID = "kanboardtaskid" + TASK_TITLE = "kanboardtasktitle" + TASK_DESCRIPTION = "kanboardtaskdescription" + PROJECT_ID = "kanboardprojectid" + PROJECT_NAME = "kanboardprojectname" + URL = "kanboardurl" + + UDAS = { + TASK_ID: {"type": "numeric", "label": "Kanboard Task ID"}, + TASK_TITLE: {"type": "string", "label": "Kanboard Task Title"}, + TASK_DESCRIPTION: {"type": "string", "label": "Kanboard Task Description"}, + PROJECT_ID: {"type": "numeric", "label": "Kanboard Project ID"}, + PROJECT_NAME: {"type": "string", "label": "Kanboard Project Name"}, + URL: {"type": "string", "label": "Kanboard URL"}, + } + UNIQUE_KEY = (TASK_ID,) + + PRIORITY_MAP = {"0": None, "1": "L", "2": "M", "3": "H"} + + def to_taskwarrior(self): + return { + "project": self.get_project(), + "priority": self.get_priority(), + "annotations": self.get_annotations(), + "tags": self.get_tags(), + "due": self.get_due(), + "entry": self.get_entry(), + self.TASK_ID: self.get_task_id(), + self.TASK_TITLE: self.get_task_title(), + self.TASK_DESCRIPTION: self.get_task_description(), + self.PROJECT_ID: self.get_project_id(), + self.PROJECT_NAME: self.get_project_name(), + self.URL: self.get_url(), + } + + def get_default_description(self): + return self.build_default_description( + title=self.get_task_title(), + url=self.get_processed_url(self.get_url()), + number=self.get_task_id(), + ) + + def get_task_id(self): + return int(self.record["id"]) + + def get_task_title(self): + return self.record["title"] + + def get_task_description(self): + return self.record["description"] + + def get_project_id(self): + return int(self.record["project_id"]) + + def get_project_name(self): + return self.record["project_name"] + + def get_project(self): + value = self.get_project_name() + value = re.sub(r"[^a-zA-Z0-9]", "_", value) + return value.strip("_") + + def get_url(self): + return self.extra["url"] + + def get_tags(self): + return self.extra.get("tags", []) + + def get_due(self): + return self._convert_timestamp_from_field("date_due") + + def get_entry(self): + return self._convert_timestamp_from_field("date_creation") + + def get_annotations(self): + return self.extra.get("annotations", []) + + def _convert_timestamp_from_field(self, field): + timestamp = int(self.record.get(field, 0)) + if timestamp: + return ( + datetime.datetime.fromtimestamp(timestamp) + .astimezone(tzutc()) + .replace(microsecond=0) + ) + + +class KanboardService(IssueService): + ISSUE_CLASS = KanboardIssue + CONFIG_PREFIX = "kanboard" + + def __init__(self, *args, **kw): + super().__init__(*args, **kw) + username = self.config.get("username") + password = self.get_password("password", username) + url = self.config.get("url").rstrip("/") + self.client = Client(f"{url}/jsonrpc.php", username, password) + default_query = f"status:open assignee:{username}" + self.query = self.config.get("query", default_query) + + def annotations(self, task, url): + comments = [] + if int(task.get("nb_comments", 0)): + comments = self.client.get_all_comments(**{"task_id": task["id"]}) + return self.build_annotations( + ((c["name"], c["comment"]) for c in comments), url + ) + + def issues(self): + # The API provides only a per-project search. Retrieve the list of + # projects first and query each project in turn. + projects = self.client.get_my_projects_list() + tasks = [] + for project_id, project_name in projects.items(): + log.debug( + "Search for tasks in project %r using query %r", + project_name, + self.query, + ) + params = {"project_id": project_id, "query": self.query} + response = self.client.search_tasks(**params) + log.debug("Found %d task(s) in project %r", len(response), project_name) + tasks.extend(response) + + for task in tasks: + task_id = task["id"] + extra = {} + + # Resolve a task's URL. + response = self.client.get_task(task_id=task_id) + extra["url"] = response["url"] + + # Resolve a task's tags. + response = self.client.get_task_tags(task_id=task_id) + extra["tags"] = [v for v in response.values()] + + # Resolve a task's comments. + extra["annotations"] = self.annotations(task, extra["url"]) + + yield self.get_issue_for_record(task, extra) + + @classmethod + def validate_config(cls, service_config, target): + for option in ("url", "username", "password"): + if option not in service_config: + die(f"[{target}] has no 'kanboard.{option}'") + + IssueService.validate_config(service_config, target) + + @staticmethod + def get_keyring_service(service_config): + parsed = urlparse(service_config.get("url")) + username = service_config.get("username") + return f"kanboard://{username}@{parsed.netloc}" diff --git a/setup.py b/setup.py index 47bdc9ad6..4cdef80fa 100644 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ "bugzilla": ["python-bugzilla>=2.0.0"], "gmail": ["google-api-python-client", "google-auth-oauthlib"], "jira": ["jira>=0.22"], + "kanboard": ["kanboard"], "keyring": ["keyring"], "megaplan": ["megaplan>=1.4"], "phabricator": ["phabricator"], @@ -60,7 +61,8 @@ "bugwarrior[gmail]", "bugwarrior[trac]", "bugwarrior[bugzilla]", - "bugwarrior[phabricator]" + "bugwarrior[phabricator]", + "bugwarrior[kanboard]", ], test_suite='nose.collector', entry_points=""" @@ -75,6 +77,7 @@ trac=bugwarrior.services.trac:TracService bts=bugwarrior.services.bts:BTSService bugzilla=bugwarrior.services.bz:BugzillaService + kanboard=bugwarrior.services.kanboard:KanboardService teamlab=bugwarrior.services.teamlab:TeamLabService redmine=bugwarrior.services.redmine:RedMineService activecollab2=bugwarrior.services.activecollab2:ActiveCollab2Service diff --git a/tests/test_kanboard.py b/tests/test_kanboard.py new file mode 100644 index 000000000..9583c5fff --- /dev/null +++ b/tests/test_kanboard.py @@ -0,0 +1,226 @@ +import configparser +import datetime +from unittest import mock + +from dateutil.tz.tz import tzutc + +from bugwarrior.config import ServiceConfig +from bugwarrior.services.kanboard import KanboardService + +from .base import AbstractServiceTest, ConfigTest, ServiceTest + + +class TestKanboardServiceConfig(ConfigTest): + def setUp(self): + super().setUp() + self.config = configparser.RawConfigParser() + self.config.add_section("general") + self.config.add_section("kb") + self.service_config = ServiceConfig( + KanboardService.CONFIG_PREFIX, self.config, "kb" + ) + + @mock.patch("bugwarrior.services.kanboard.die") + def test_validate_config_required_fields(self, die): + self.config.set("kb", "kanboard.url", "http://example.com/") + self.config.set("kb", "kanboard.username", "myuser") + self.config.set("kb", "kanboard.password", "mypass") + KanboardService.validate_config(self.service_config, "kb") + die.assert_not_called() + + @mock.patch("bugwarrior.services.kanboard.die") + def test_validate_config_no_url(self, die): + self.config.set("kb", "kanboard.username", "myuser") + self.config.set("kb", "kanboard.password", "mypass") + KanboardService.validate_config(self.service_config, "kb") + die.assert_called_with("[kb] has no 'kanboard.url'") + + @mock.patch("bugwarrior.services.kanboard.die") + def test_validate_config_no_username(self, die): + self.config.set("kb", "kanboard.url", "http://one.com/") + self.config.set("kb", "kanboard.password", "mypass") + KanboardService.validate_config(self.service_config, "kb") + die.assert_called_with("[kb] has no 'kanboard.username'") + + @mock.patch("bugwarrior.services.kanboard.die") + def test_validate_config_no_password(self, die): + self.config.set("kb", "kanboard.url", "http://one.com/") + self.config.set("kb", "kanboard.username", "myuser") + KanboardService.validate_config(self.service_config, "kb") + die.assert_called_with("[kb] has no 'kanboard.password'") + + def test_get_keyring_service(self): + self.config.set("kb", "kanboard.url", "http://example.com/") + self.config.set("kb", "kanboard.username", "myuser") + self.assertEqual( + KanboardService.get_keyring_service(self.service_config), + "kanboard://myuser@example.com", + ) + + +class TestKanboardService(AbstractServiceTest, ServiceTest): + SERVICE_CONFIG = { + "kanboard.url": "http://example.com", + "kanboard.username": "myuser", + "kanboard.password": "mypass", + } + + def setUp(self): + super().setUp() + with mock.patch("kanboard.Client"): + self.service = self.get_mock_service(KanboardService) + + def get_mock_service(self, *args, **kwargs): + service = super().get_mock_service(*args, **kwargs) + service.client = mock.MagicMock() + return service + + def test_annotations_zero_comments(self): + task = {"id": 1, "nb_comments": 0} + url = "ignore" + + annotations = self.service.annotations(task, url) + + self.assertListEqual(annotations, []) + self.service.client.get_all_comments.assert_not_called() + + def test_annotations_some_comments(self): + task = {"id": 1, "nb_comments": 2} + url = "ignore" + + self.service.client.get_all_comments.return_value = [ + {"name": "a", "comment": "c1"}, + {"name": "b", "comment": "c2"}, + ] + annotations = self.service.annotations(task, url) + + self.assertListEqual(annotations, ["@a - c1", "@b - c2"]) + self.service.client.get_all_comments.assert_called_once_with(task_id=1) + + def test_to_taskwarrior(self): + record = { + "project_id": "2", + "project_name": "myproject", + "priority": "2", + "date_due": "0", + "date_creation": "1434227446", + "id": "1", + "title": "mytitle", + "description": "mydescription", + } + + extra = { + "url": "http://path/to/issue", + "annotations": [ + "One", + "Two", + ], + "tags": [ + "tag", + ], + } + + issue = self.service.get_issue_for_record(record, extra) + + expected_output = { + "project": record["project_name"], + "priority": issue.PRIORITY_MAP[record["priority"]], + "annotations": extra["annotations"], + "tags": extra["tags"], + "due": None, + "entry": datetime.datetime(2015, 6, 13, 20, 30, 46, tzinfo=tzutc()), + issue.TASK_ID: int(record["id"]), + issue.TASK_TITLE: record["title"], + issue.TASK_DESCRIPTION: record["description"], + issue.PROJECT_ID: int(record["project_id"]), + issue.PROJECT_NAME: record["project_name"], + issue.URL: extra["url"], + } + actual_output = issue.to_taskwarrior() + + self.assertEqual(actual_output, expected_output) + + def test_issues(self): + # Setup the fake client + self.service.client.get_my_projects_list.return_value = {"1": "project"} + self.service.client.search_tasks.return_value = [ + { + "nb_comments": "0", + "nb_files": "0", + "nb_subtasks": "0", + "nb_completed_subtasks": "0", + "nb_links": "0", + "nb_external_links": "0", + "id": "3", + "reference": "", + "title": "T3", + "description": "D3", + "date_creation": "1461365164", + "date_modification": "1461365164", + "date_due": "0", + "color_id": "yellow", + "project_id": "1", + "project_name": "project", + "column_id": "5", + "swimlane_id": "0", + "owner_id": "0", + "creator_id": "0", + } + ] + self.service.client.get_task.return_value = { + "id": "3", + "title": "Task #3", + "description": "", + "date_creation": "1409963206", + "color_id": "blue", + "project_id": "1", + "column_id": "2", + "owner_id": "1", + "position": "1", + "is_active": "1", + "score": "0", + "date_due": "0", + "category_id": "0", + "creator_id": "0", + "date_modification": "1409963206", + "reference": "", + "time_spent": "0", + "time_estimated": "0", + "swimlane_id": "0", + "date_moved": "1430875287", + "recurrence_status": "0", + "recurrence_trigger": "0", + "recurrence_factor": "0", + "recurrence_timeframe": "0", + "recurrence_basedate": "0", + "url": "http://example.com?task_id=3&project_id=1", + } + self.service.client.get_task_tags.return_value = {"1": "tag1", "2": "tag2"} + + issue = next(self.service.issues()) + + # Check calls on the client + self.service.client.get_my_projects_list.assert_called_once_with() + self.service.client.search_tasks.assert_called_once_with( + project_id="1", query=self.service.query + ) + self.service.client.get_task.assert_called_once_with(task_id="3") + self.service.client.get_task_tags.assert_called_once_with(task_id="3") + + expected = { + "description": "(bw)Is#3 - T3 .. http://example.com?task_id=3&project_id=1", + "due": None, + "entry": datetime.datetime(2016, 4, 22, 22, 46, 4, tzinfo=tzutc()), + "annotations": [], + "project": "project", + "tags": ["tag1", "tag2"], + "kanboardtaskid": 3, + "kanboardurl": "http://example.com?task_id=3&project_id=1", + "kanboardprojectid": 1, + "kanboardprojectname": "project", + "kanboardtaskdescription": "D3", + "kanboardtasktitle": "T3", + "priority": "M", # default priority + } + + self.assertEqual(issue.get_taskwarrior_record(), expected)