diff --git a/zulip_bots/setup.py b/zulip_bots/setup.py index c3e02dc..7279bc3 100755 --- a/zulip_bots/setup.py +++ b/zulip_bots/setup.py @@ -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 diff --git a/zulip_bots/zulip_bots/bots/jira/doc.md b/zulip_bots/zulip_bots/bots/jira/doc.md new file mode 100644 index 0000000..9916a27 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/jira/doc.md @@ -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 diff --git a/zulip_bots/zulip_bots/bots/jira/jira.conf b/zulip_bots/zulip_bots/bots/jira/jira.conf new file mode 100644 index 0000000..86c2559 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/jira/jira.conf @@ -0,0 +1,4 @@ +[jira] +username = +password = +domain = diff --git a/zulip_bots/zulip_bots/bots/jira/jira.py b/zulip_bots/zulip_bots/bots/jira/jira.py new file mode 100644 index 0000000..13c3c90 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/jira/jira.py @@ -0,0 +1,351 @@ +import base64 +import re +import requests +from typing import Any, Dict, Iterable, Optional + +GET_REGEX = re.compile('get "(?P.+)"$') +CREATE_REGEX = re.compile( + 'create issue "(?P.+?)"' + ' in project "(?P.+?)"' + ' with type "(?P.+?)"' + '( with description "(?P.+?)")?' + '( assigned to "(?P.+?)")?' + '( with priority "(?P.+?)")?' + '( labeled "(?P.+?)")?' + '( due "(?P.+?)")?' + '$' +) +EDIT_REGEX = re.compile( + 'edit issue "(?P.+?)"' + '( to use summary "(?P.+?)")?' + '( to use project "(?P.+?)")?' + '( to use type "(?P.+?)")?' + '( to use description "(?P.+?)")?' + '( by assigning to "(?P.+?)")?' + '( to use priority "(?P.+?)")?' + '( by labeling "(?P.+?)")?' + '( by making due "(?P.+?)")?' + '$' +) +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: '. + + 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 diff --git a/zulip_bots/zulip_bots/bots/jira/test_jira.py b/zulip_bots/zulip_bots/bots/jira/test_jira.py new file mode 100644 index 0000000..a8020d2 --- /dev/null +++ b/zulip_bots/zulip_bots/bots/jira/test_jira.py @@ -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)