Creating a New Service¶
In this tutorial we will walk through the process of writing a new service from scratch with examples from git-bug. In the process we will get a high level overview of each component of a service. Let’s get started!
1. API Access¶
The first step is figuring out how you’re going to establish a connection to your service’s API.
You may choose to use an existing python client for accessing the API if an existing library already exists. If you’re going this route, be sure to add an entry to the extras
dictionary in setup.py
. You should also go ahead and test this library out in a python interpreter and make sure you can authenticate with an external server if necessary.
More likely you’ll be writing your own client using an http API, so start off by making sure you can access it on the command line with, for example, curl.
curl 'http://127.0.0.1:12345/graphql' \
-H 'Content-Type: application/json' \
--data-binary '{"query":"{ repository { allBugs { nodes { title } } } }"}'
This example of accessing a local service is quite simple, but you’ll likely need to pass additional arguments and perhaps go through a handshake process to authenticate to a remote server.
2. Service File¶
Add a python file with the name of your service in bugwarrior/services
.
touch bugwarrior/services/gitbug.py
3. Imports¶
Fire up your favorite editor and import the base classes and whatever library you’re using to access your service.
import logging
import pathlib
import requests
import typing_extensions
from bugwarrior import config
from bugwarrior.services import IssueService, Issue, ServiceClient
log = logging.getLogger(__name__)
4. Configuration Schema¶
Now define an initial configuration schema as follows. Don’t worry, we’re about to break this down!
class GitbugConfig(config.ServiceConfig):
service: typing_extensions.Literal['gitbug']
path: pathlib.Path
import_labels_as_tags: bool = False
label_template: str = '{{label}}'
port: int = 43915
This class is a pydantic model which we use to define which configuration options are available for the service, validate user configurations, and pass these values on to the service.
The service
attribute is how bugwarrior will know to assign a given section of the bugwarriorrc
file to your service, for example:
[my_gitbug]
service = gitbug
[my_gitbug]
service = "gitbug"
The path
is the only particular detail required to access our local git-bug instance. You’ll likely need additional details such as a username and token to authenticate to the service. Look at how you accessed the API in step 1 and ask yourself which components need to be configurable.
The import_labels_as_tags
and port
attributes create optional configuration fields to allow customization of bugwarrior behavior.
4. Client¶
Unless you’re using a library that closely aligns with the needs of your service class, you’ll probably want a client class. The purpose of this class is to abstract away the details of getting the data we need from the API – authenticating, querying, paging, de-serializing, etc. – so your service can focus on the business of translating service data into taskwarrior tasks.
class GitBugClient(ServiceClient):
def __init__(self, path, port):
self.path = path
self.port = port
def _query_graphql(self, query):
response = requests.post(
f'http://127.0.0.1:{self.port}/graphql',
json={'query': query})
return self.json_response(response)['data']
def get_issues(self):
return self._query_graphql('{ repository { allBugs { nodes { title } } } }')
As you see, our client provides a simple API to execute the same API query we did in step 1. We can come back and add the additional fields bugwarrior will need to fetch later.
5. Issue¶
We will now implement an Issue
class, which is essentially a wrapper for each task you’re pulling in. This provides a consistent API across services, which enables bugwarrior to synchronize arbitrary tasks without knowing anything about the service they come from.
class GitbugIssue(Issue):
AUTHOR = 'gitbugauthor'
ID = 'gitbugid'
STATE = 'gitbugstate'
TITLE = 'gitbugtitle'
UDAS = {
AUTHOR: {
'type': 'string',
'label': 'Gitbug Issue Author',
},
ID: {
'type': 'string',
'label': 'Gitbug UUID',
},
STATE: {
'type': 'string',
'label': 'Gitbug state',
},
TITLE: {
'type': 'string',
'label': 'Gitbug Title',
},
}
UNIQUE_KEY = (ID,)
def to_taskwarrior(self):
return {
'project': self.extra['project'],
'priority': self.origin['default_priority'],
'annotations': self.record.get('annotations', []),
'tags': self.get_tags(),
'entry': self.parse_date(self.record.get('createdAt')),
self.AUTHOR: self.record['author']['name'],
self.ID: self.record['id'],
self.STATE: self.record['state'],
self.TITLE: self.record['title'],
}
def get_tags(self):
return self.get_tags_from_labels(
[label['name'] for label in self.record['labels']])
def get_default_description(self):
return self.build_default_description(title=self.record['title'])
The first thing you see here is the declaration of which UDAs this service will assign to each task. The first set of class attributes define the UDA names – e.g. the author will be assigned to gitbugauthor
– and the UDAS
dictionary provides additional metadata about them.
The UNIQUE_KEY
attribute must be assigned a tuple of UDAs which are sufficient to identify a task. Keep in mind that these will be used to update tasks when their remote content changes, so the selected UDAs must be immutable.
There are two abstract methods which now must be implemented: to_taskwarrior
and get_default_description
.
The first must return a dictionary of attributes – both the standard attributes and UDAs – pointing to their content in a given issue. This content will largely be found in the record
and extra
attributes, which we will get to later.
The get_default_description
method must return a string representation of the task using the build_default_description
method, which takes the following keyword arguments, all optional:
title
url
number
cls (a categorization of the type of task, defaulting to “issue”)
6. Service¶
Now for the main service class which bugwarrior will invoke to fetch issues.
class GitBugService(IssueService):
ISSUE_CLASS = GitBugIssue
CONFIG_SCHEMA = GitBugConfig
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.client = GitBugClient(path=self.config.path, port=self.config.port)
def get_service_metadata(self):
return {
'import_labels_as_tags': self.config.import_labels_as_tags,
'label_template': self.config.label_template,
}
def get_owner(self, issue):
# Issue assignment hasn't been implemented in upstream git-bug yet.
# See https://github.com/MichaelMure/git-bug/issues/112.
raise NotImplementedError(
"This service has not implemented support for 'only_if_assigned'.")
def issues(self):
for issue in self.client.get_issues():
comments = issue.pop('comments')
issue['description'] = comments['nodes'].pop(0)['message']
if self.main_config.annotation_comments:
annotations = ((
comment['author']['name'],
comment['message']
) for comment in comments['nodes'])
issue['annotations'] = self.build_annotations(annotations)
yield self.get_issue_for_record(issue)
Here we see two required class attributes (pointing to the classes we previously defined) and two required methods.
The get_service_metadata
method is not required, but can be used to expose additional data in the Issue.origin
attribute.
The get_owner
method takes an individual issue and returns the “assigned” user, so that bugwarrior can filter issues on this basis. In this case git-bug has not yet implemented this feature, but it generally will just involve returning a value from the issue
dictionary.
The issues
method is a generator which yields individual issue dictionaries.
7. Service Registration¶
Add your service class as an entry_point
under the [bugwarrior.service]
section in setup.py
.
gitbug=bugwarrior.services.gitbug:GitBugService
8. Tests¶
Create a test file and implement at least the minimal service tests by inheriting from AbstractServiceTest
.
touch tests/test_gitbug.py
class TestGitBugIssue(AbstractServiceTest, ServiceTest):
SERVICE_CONFIG = {
'service': 'gitbug',
'path': '/dev/null',
}
def setUp(self):
super().setUp()
self.data = TestData()
self.service = self.get_mock_service(GitBugService)
self.service.client = mock.MagicMock(spec=GitBugClient)
self.service.client.get_issues = mock.MagicMock(
return_value=[self.data.arbitrary_bug])
def test_to_taskwarrior(self):
issue = self.service.get_issue_for_record(
self.data.arbitrary_bug, {})
expected = { ... }
actual = issue.to_taskwarrior()
self.assertEqual(actual, expected)
def test_issues(self):
issue = next(self.service.issues())
expected = { ... }
self.assertEqual(issue.get_taskwarrior_record(), expected)
9. Documentation¶
Create a documentation file and include the relevant sections.
touch bugwarrior/docs/services/gitbug.rst
Copy and complete the following template:
SERVICE_NAME
============
You can import tasks from your SERVICE_NAME instance using the ``SERVICE`` service name.
EXTRA DEPENDENCY INSTALLATION INSTRUCTIONS, IF NEEDED
Example Service
---------------
Here's an example of a SERVICE_NAME target:
.. config::
[my_issue_tracker]
service = SERVICE
ADDITIONAL REQUIRED CONFIGURATION OPTIONS, IN INI FORMAT
The above example is the minimum required to import issues from SERVICE_NAME.
You can also feel free to use any of the configuration options described in :ref:`common_configuration_options` or described in `Service Features`_ below.
EXPLAIN THE ADDITIONAL REQUIRED CONFIGURATION OPTIONS
Service Features
----------------
ADD SECTIONS HERE TO COVER EACH OPTIONAL CONFIGURATION OPTION.
SOME OPTIONS WILL NEED THEIR OWN SECTION WHILE OTHERS MAKE SENSE TO GROUP TOGETHER.
Provided UDA Fields
-------------------
.. udas:: bugwarrior.services.SERVICE_MODULE.ISSUE_CLASS
10. README¶
Update the list of services in README.rst
with a link to the homepage of your service.