bots: Create Jira Bot.

Users can get an issue from Jira Bot using it's key, and get a response
like the following:

    Issue BOTS-13: Create Jira Bot
     - Type: Task
     - Creator: skunkmb
     - Project: Bots
     - Priority: Medium
     - Status: To Do

Users can create or edit an issue with Jira Bot with its
 - summary,
 - project,
 - type,
 - description,
 - assignee,
 - priority,
 - labels, and
 - due date
This commit is contained in:
Marco Burstein 2017-12-24 12:45:31 -08:00 committed by showell
parent d8c6cb7c0a
commit f719964487
5 changed files with 523 additions and 1 deletions

View file

@ -52,7 +52,7 @@ setuptools_info = dict(
'html2text', # for bots/define
'BeautifulSoup4', # for bots/googlesearch
'lxml', # for bots/googlesearch
'requests', # for bots/link_shortener
'requests', # for bots/link_shortener and bots/jira
'python-chess[engine,gaviota]', # for bots/chess
'wit', # for bots/witai
'apiai' # for bots/dialogflow

View file

@ -0,0 +1,81 @@
# Jira Bot
## Setup
To use Jira Bot, first set up `jira.conf`. `jira.conf` takes 3 options:
- username (an email or username that can access your Jira),
- password (the password for that username), and
- domain (a domain like `example.atlassian.net`)
## Usage
### get
`get` takes in an issue key and sends back information about that issue. For example,
you:
> @**Jira Bot** get "BOTS-13"
Jira Bot:
> **Issue *BOTS-13*: Create Jira Bot**
>
> - Type: *Task*
> - Description:
> > Jira Bot would connect to Jira.
> - Creator: *admin*
> - Project: *Bots*
> - Priority: *Medium*
> - Status: *To Do*
### create
`create` creates an issue using its
- summary,
- project,
- type,
- description *(optional)*,
- assignee *(optional)*,
- priority *(optional)*,
- labels *(optional)*, and
- due date *(optional)*
For example, to create an issue with every option,
you:
> @**Jira Bot** create issue "Make an issue" in project "BOTS"' with type "Task" with description
> "This is a description" assigned to "skunkmb" with priority "Medium" labeled "issues, testing"
> due "2017-01-23"
Jira Bot:
> Issue *BOTS-16* is up! https://example.atlassian.net/browse/BOTS-16
### edit
`edit` is like create, but changes an existing issue using its
- summary,
- project *(optional)*,
- type *(optional)*,
- description *(optional)*,
- assignee *(optional)*,
- priority *(optional)*,
- labels *(optional)*, and
- due date *(optional)*.
For example, to change every part of an issue,
you:
> @**Jira Bot** edit issue "BOTS-16" to use summary "Change the summary" to use project
> "NEWBOTS" to use type "Bug" to use description "This is a new description" by assigning
> to "admin" to use priority "Low" by labeling "new, labels" by making due "2018-12-5"
Jira Bot:
> Issue *BOTS-16* was edited! https://example.atlassian.net/browse/BOTS-16

View file

@ -0,0 +1,4 @@
[jira]
username = <your Jira email address>
password = <your Jira password>
domain = <your Jira domain>

View file

@ -0,0 +1,351 @@
import base64
import re
import requests
from typing import Any, Dict, Iterable, Optional
GET_REGEX = re.compile('get "(?P<issue_key>.+)"$')
CREATE_REGEX = re.compile(
'create issue "(?P<summary>.+?)"'
' in project "(?P<project_key>.+?)"'
' with type "(?P<type_name>.+?)"'
'( with description "(?P<description>.+?)")?'
'( assigned to "(?P<assignee>.+?)")?'
'( with priority "(?P<priority_name>.+?)")?'
'( labeled "(?P<labels>.+?)")?'
'( due "(?P<due_date>.+?)")?'
'$'
)
EDIT_REGEX = re.compile(
'edit issue "(?P<issue_key>.+?)"'
'( to use summary "(?P<summary>.+?)")?'
'( to use project "(?P<project_key>.+?)")?'
'( to use type "(?P<type_name>.+?)")?'
'( to use description "(?P<description>.+?)")?'
'( by assigning to "(?P<assignee>.+?)")?'
'( to use priority "(?P<priority_name>.+?)")?'
'( by labeling "(?P<labels>.+?)")?'
'( by making due "(?P<due_date>.+?)")?'
'$'
)
HELP_REGEX = re.compile('help$')
HELP_RESPONSE = '''
**get**
`get` takes in an issue key and sends back information about that issue. For example,
you:
> @**Jira Bot** get "BOTS-13"
Jira Bot:
> **Issue *BOTS-13*: Create Jira Bot**
>
> - Type: *Task*
> - Description:
> > Jira Bot would connect to Jira.
> - Creator: *admin*
> - Project: *Bots*
> - Priority: *Medium*
> - Status: *To Do*
---
**create**
`create` creates an issue using its
- summary,
- project,
- type,
- description *(optional)*,
- assignee *(optional)*,
- priority *(optional)*,
- labels *(optional)*, and
- due date *(optional)*
For example, to create an issue with every option,
you:
> @**Jira Bot** create issue "Make an issue" in project "BOTS"' with type \
"Task" with description "This is a description" assigned to "skunkmb" with \
priority "Medium" labeled "issues, testing" due "2017-01-23"
Jira Bot:
> Issue *BOTS-16* is up! https://example.atlassian.net/browse/BOTS-16
---
**edit**
`edit` is like create, but changes an existing issue using its
- summary,
- project *(optional)*,
- type *(optional)*,
- description *(optional)*,
- assignee *(optional)*,
- priority *(optional)*,
- labels *(optional)*, and
- due date *(optional)*.
For example, to change every part of an issue,
you:
> @**Jira Bot** edit issue "BOTS-16" to use summary "Change the summary" \
to use project "NEWBOTS" to use type "Bug" to use description "This is \
a new description" by assigning to "admin" to use priority "Low" by \
labeling "new, labels" by making due "2018-12-5"
Jira Bot:
> Issue *BOTS-16* was edited! https://example.atlassian.net/browse/BOTS-16
'''
class JiraHandler(object):
def usage(self) -> str:
return '''
Jira Bot uses the Jira REST API to interact with Jira. In order to use
Jira Bot, `jira.conf` must be set up. See `doc.md` for more details.
'''
def initialize(self, bot_handler: Any) -> None:
config = bot_handler.get_config_info('jira')
username = config.get('username')
password = config.get('password')
domain = config.get('domain')
if not username:
raise KeyError('No `username` was specified')
if not password:
raise KeyError('No `password` was specified')
if not domain:
raise KeyError('No `domain` was specified')
self.auth = make_jira_auth(username, password)
self.domain_with_protocol = 'https://' + domain
def handle_message(self, message: Dict[str, str], bot_handler: Any) -> None:
content = message.get('content')
response = ''
get_match = GET_REGEX.match(content)
create_match = CREATE_REGEX.match(content)
edit_match = EDIT_REGEX.match(content)
help_match = HELP_REGEX.match(content)
if get_match:
UNKNOWN_VAL = '*unknown*'
key = get_match.group('issue_key')
jira_response = requests.get(
self.domain_with_protocol + '/rest/api/2/issue/' + key,
headers={'Authorization': self.auth},
).json()
url = self.domain_with_protocol + '/browse/' + key
errors = jira_response.get('errorMessages', [])
fields = jira_response.get('fields', {})
creator_name = fields.get('creator', {}).get('name', UNKNOWN_VAL)
description = fields.get('description', UNKNOWN_VAL)
priority_name = fields.get('priority', {}).get('name', UNKNOWN_VAL)
project_name = fields.get('project', {}).get('name', UNKNOWN_VAL)
type_name = fields.get('issuetype', {}).get('name', UNKNOWN_VAL)
status_name = fields.get('status', {}).get('name', UNKNOWN_VAL)
summary = fields.get('summary', UNKNOWN_VAL)
if errors:
response = 'Oh no! Jira raised an error:\n > ' + ', '.join(errors)
else:
response = (
'**Issue *[{0}]({1})*: {2}**\n\n'
' - Type: *{3}*\n'
' - Description:\n'
' > {4}\n'
' - Creator: *{5}*\n'
' - Project: *{6}*\n'
' - Priority: *{7}*\n'
' - Status: *{8}*\n'
).format(key, url, summary, type_name, description, creator_name, project_name,
priority_name, status_name)
elif create_match:
jira_response = requests.post(
self.domain_with_protocol + '/rest/api/2/issue',
headers={'Authorization': self.auth, 'Content-type': 'application/json'},
data=make_create_json(create_match.group('summary'),
create_match.group('project_key'),
create_match.group('type_name'),
create_match.group('description'),
create_match.group('assignee'),
create_match.group('priority_name'),
create_match.group('labels'),
create_match.group('due_date'))
)
jira_response_json = jira_response.json() if jira_response.text else {}
key = jira_response_json.get('key', '')
url = self.domain_with_protocol + '/browse/' + key
errors = list(jira_response_json.get('errors', {}).values())
if errors:
response = 'Oh no! Jira raised an error:\n > ' + ', '.join(errors)
else:
response = 'Issue *' + key + '* is up! ' + url
elif edit_match and check_is_editing_something(edit_match):
key = edit_match.group('issue_key')
jira_response = requests.put(
self.domain_with_protocol + '/rest/api/2/issue/' + key,
headers={'Authorization': self.auth, 'Content-type': 'application/json'},
data=make_edit_json(edit_match.group('summary'),
edit_match.group('project_key'),
edit_match.group('type_name'),
edit_match.group('description'),
edit_match.group('assignee'),
edit_match.group('priority_name'),
edit_match.group('labels'),
edit_match.group('due_date'))
)
jira_response_json = jira_response.json() if jira_response.text else {}
url = self.domain_with_protocol + '/browse/' + key
errors = list(jira_response_json.get('errors', {}).values())
if errors:
response = 'Oh no! Jira raised an error:\n > ' + ', '.join(errors)
else:
response = 'Issue *' + key + '* was edited! ' + url
elif help_match:
response = HELP_RESPONSE
else:
response = 'Sorry, I don\'t understand that! Send me `help` for instructions.'
bot_handler.send_reply(message, response)
def make_jira_auth(username: str, password: str) -> str:
'''Makes an auth header for Jira in the form 'Basic: <encoded credentials>'.
Parameters:
- username: The Jira email address.
- password: The Jira password.
'''
combo = username + ':' + password
encoded = base64.b64encode(combo.encode('utf-8')).decode('utf-8')
return 'Basic ' + encoded
def make_create_json(summary: str, project_key: str, type_name: str,
description: Optional[str], assignee: Optional[str],
priority_name: Optional[str], labels: Optional[str],
due_date: Optional[str]) -> str:
'''Makes a JSON string for the Jira REST API editing endpoint based on
fields that could be edited.
Parameters:
- summary: The Jira summary property.
- project_key: The Jira project key property.
- type_name (optional): The Jira type name property.
- description (optional): The Jira description property.
- assignee (optional): The Jira assignee property.
- priority_name (optional): The Jira priority name property.
- labels (optional): The Jira labels property, as a string of labels separated by
comma-spaces.
- due_date (optional): The Jira due date property.
'''
json = '{"fields": {'
json += '"summary": "' + summary + '",'
json += '"project": { "key": "' + project_key + '" },'
json += '"issuetype": { "name": "' + type_name + '" },'
if description:
json += '"description": "' + description + '",'
if assignee:
json += '"assignee": { "name": "' + assignee + '" },'
if priority_name:
json += '"priority": { "name": "' + priority_name + '" },'
if labels:
labels_list = labels.split(', ')
labels_json = '"' + '","'.join(labels_list) + '"'
json += '"labels": [' + labels_json + '],'
if due_date:
json += '"duedate": "' + due_date + '",'
# Remove the trailing comma.
json = json[:-1]
json += '}}'
return json
def make_edit_json(summary: Optional[str], project_key: Optional[str],
type_name: Optional[str], description: Optional[str],
assignee: Optional[str], priority_name: Optional[str],
labels: Optional[str], due_date: Optional[str]) -> str:
'''Makes a JSON string for the Jira REST API editing endpoint based on
fields that could be edited.
Parameters:
- summary (optional): The Jira summary property.
- project_key (optional): The Jira project key property.
- type_name (optional): The Jira type name property.
- description (optional): The Jira description property.
- assignee (optional): The Jira assignee property.
- priority_name (optional): The Jira priority name property.
- labels (optional): The Jira labels property, as a string of labels separated by
comma-spaces.
- due_date (optional): The Jira due date property.
'''
json = '{"fields": {'
if summary:
json += '"summary": "' + summary + '",'
if project_key:
json += '"project": { "key": "' + project_key + '" },'
if type_name:
json += '"issuetype": { "name": "' + type_name + '" },'
if description:
json += '"description": "' + description + '",'
if assignee:
json += '"assignee": { "name": "' + assignee + '" },'
if priority_name:
json += '"priority": { "name": "' + priority_name + '" },'
if labels:
labels_list = labels.split(', ')
labels_json = '"' + '","'.join(labels_list) + '"'
json += '"labels": [' + labels_json + '],'
if due_date:
json += '"duedate": "' + due_date + '",'
# Remove the trailing comma.
json = json[:-1]
json += '}}'
return json
def check_is_editing_something(match: Any) -> bool:
'''Checks if an editing match is actually going to do editing. It is
possible for an edit regex to match without doing any editing because each
editing field is optional. For example, 'edit issue "BOTS-13"' would pass
but wouldn't preform any actions.
Parameters:
- match: The regex match object.
'''
return bool(
match.group('summary') or
match.group('project_key') or
match.group('type_name') or
match.group('description') or
match.group('assignee') or
match.group('priority_name') or
match.group('labels') or
match.group('due_date')
)
handler_class = JiraHandler

View file

@ -0,0 +1,86 @@
from mock import patch
from zulip_bots.test_lib import BotTestCase
class TestJiraBot(BotTestCase):
bot_name = 'jira'
MOCK_CONFIG_INFO = {
'username': 'example@example.com',
'password': 'qwerty!123',
'domain': 'example.atlassian.net'
}
MOCK_GET_JSON = {
'fields': {
'creator': {'name': 'admin'},
'description': 'description',
'priority': {'name': 'Medium'},
'project': {'name': 'Tests'},
'issuetype': {'name': 'Task'},
'status': {'name': 'To Do'},
'summary': 'summary'
}
}
MOCK_GET_RESPONSE = '''\
**Issue *[TEST-13](https://example.atlassian.net/browse/TEST-13)*: summary**
- Type: *Task*
- Description:
> description
- Creator: *admin*
- Project: *Tests*
- Priority: *Medium*
- Status: *To Do*
'''
MOCK_CREATE_JSON = {
'key': 'TEST-16'
}
MOCK_CREATE_RESPONSE = 'Issue *TEST-16* is up! https://example.atlassian.net/browse/TEST-16'
MOCK_EDIT_JSON = {}
MOCK_EDIT_RESPONSE = 'Issue *TEST-16* was edited! https://example.atlassian.net/browse/TEST-16'
MOCK_NOTHING_RESPONSE = 'Sorry, I don\'t understand that! Send me `help` for instructions.'
def test_get(self) -> None:
with patch('requests.get') as response, \
self.mock_config_info(self.MOCK_CONFIG_INFO):
response.return_value.text = 'text so that it isn\'t assumed to be an error'
response.return_value.json = lambda: self.MOCK_GET_JSON
self.verify_reply('get "TEST-13"', self.MOCK_GET_RESPONSE)
def test_create(self) -> None:
with patch('requests.post') as response, \
self.mock_config_info(self.MOCK_CONFIG_INFO):
response.return_value.text = 'text so that it isn\'t assumed to be an error'
response.return_value.json = lambda: self.MOCK_CREATE_JSON
self.verify_reply(
'create issue "Testing" in project "TEST" with type "Task"',
self.MOCK_CREATE_RESPONSE
)
def test_edit(self) -> None:
with patch('requests.put') as response, \
self.mock_config_info(self.MOCK_CONFIG_INFO):
response.return_value.text = 'text so that it isn\'t assumed to be an error'
response.return_value.json = lambda: self.MOCK_EDIT_JSON
self.verify_reply(
'edit issue "TEST-16" to use description "description"',
self.MOCK_EDIT_RESPONSE
)
# This overrides the default one in `BotTestCase`.
def test_bot_responds_to_empty_message(self) -> None:
with self.mock_config_info(self.MOCK_CONFIG_INFO):
self.verify_reply('', self.MOCK_NOTHING_RESPONSE)
def test_no_command(self) -> None:
with self.mock_config_info(self.MOCK_CONFIG_INFO):
self.verify_reply('qwertyuiop', self.MOCK_NOTHING_RESPONSE)