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:
parent
d8c6cb7c0a
commit
f719964487
|
@ -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
|
||||
|
|
81
zulip_bots/zulip_bots/bots/jira/doc.md
Normal file
81
zulip_bots/zulip_bots/bots/jira/doc.md
Normal 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
|
4
zulip_bots/zulip_bots/bots/jira/jira.conf
Normal file
4
zulip_bots/zulip_bots/bots/jira/jira.conf
Normal file
|
@ -0,0 +1,4 @@
|
|||
[jira]
|
||||
username = <your Jira email address>
|
||||
password = <your Jira password>
|
||||
domain = <your Jira domain>
|
351
zulip_bots/zulip_bots/bots/jira/jira.py
Normal file
351
zulip_bots/zulip_bots/bots/jira/jira.py
Normal 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
|
86
zulip_bots/zulip_bots/bots/jira/test_jira.py
Normal file
86
zulip_bots/zulip_bots/bots/jira/test_jira.py
Normal 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)
|
Loading…
Reference in a new issue