#!/usr/bin/env python from __future__ import print_function import json import logging import re from base64 import b64decode, b64encode from urllib2 import Request, urlopen, URLError, HTTPError log = logging.getLogger(__name__) class SlackJiraLinkBot(object): """ A Bot that scans slack messages for mentions of potential JIRA Issues and responds with information about the Issue """ # The domain for JIRA (eg. https://example.atlassian.net) jira_domain = None # The JIRA username to use for JIRA Basic Authentication. jira_user = None # The JIRA password to user for JIRA Basic Authentication jira_password = None # The REST API base path for JIRA Issues # Default: /rest/api/2/issue/ jira_issue_path = '/rest/api/2/issue/' # Regex used to detect when JIRA Issues are mentioned in a Slack Message # Default: [A-Z]{2,}-\d+ jira_issue_regex = '[A-Z]{2,}-\d+' # The Slack Incoming WebHook URL for sending messages slack_webhook_url = None # A list of valid slack tokens that come from any Slack Outgoing WebHooks # An empty list will accept any token. # Default: () slack_valid_tokens = () # Username to post Slack messages under. Note: does not need to be a real user. # Default: JIRA slack_user = 'JIRA' # Icon URL for the Slack user. # Default: (JIRA icon) https://slack.global.ssl.fastly.net/66f9/img/services/jira_128.png slack_user_icon = 'https://slack.global.ssl.fastly.net/66f9/img/services/jira_128.png' # Colors to use for JIRA issues. # These should match the issue types you have set up in JIRA. colors = { 'New Feature': '#65AC43', # Apple 'Bug': '#D24331', # Valencia 'Task': '#377DC6', # Tufts Blue 'Sub-Task': '#377DC6', # Tufts Blue 'Epic': '#654783', # Gigas 'Question': '#707070', # Dove Grey 'DEFAULT': '#F5F5F5' # Wild Sand } def __init__(self, jira_domain, jira_user, jira_password, slack_webhook_url, slack_valid_tokens=slack_valid_tokens, jira_issue_path=jira_issue_path, jira_issue_regex=jira_issue_regex, slack_user=slack_user, slack_user_icon=slack_user_icon, colors=colors, log_level='INFO'): self.jira_domain = jira_domain self.jira_user = jira_user self.jira_password = jira_password self.slack_webhook_url = slack_webhook_url self.slack_valid_tokens = slack_valid_tokens self.jira_issue_path = jira_issue_path self.jira_issue_regex = re.compile(jira_issue_regex) self.slack_user = slack_user self.slack_user_icon = slack_user_icon self.colors = colors log.setLevel(log_level) @staticmethod def _make_request(url, body=None, headers={}): if 'https://' not in url: url = 'https://' + url req = Request(url, body, headers) log.info('Making request to %s', url) try: response = urlopen(req) body = response.read() try: return json.loads(body) except ValueError: return body except HTTPError as e: log.error("Request failed: %d %s", e.code, e.reason) except URLError as e: log.error("Server connection failed: %s", e.reason) def _process_issue(self, key, channel): """ Gets information for a JIRA issue and sends a message to Slack with information about the issue. :param key: The JIRA issue key (eg. JIRA-1234) """ issue = self.jira_issue(key) if issue is not None: self.send_message(issue, channel) def on_message(self, event): """ Parses a message even coming from a Slack outgoing webhook then determines if any JIRA issues exist in the message and send a Slack notification to the channel with information about the issue :param event: The Slack outgoing webhook payload """ log.info('Processing Event: %s', event) # Validate Slack Tokens if self.slack_valid_tokens and event['token'] not in self.slack_valid_tokens: log.error('Request token (%s) is invalid', event['token']) raise Exception('Invalid request token') # Find all JIRA issues in a message message = event.get('text', '') matches = self.jira_issue_regex.findall(message) if len(matches) == 0: log.info('No issues found in (%s)', message) return for key in matches: self._process_issue(key, event['channel_name']) def jira_issue(self, key): """ Makes a call to the JIRA REST API to retrieve JIRA Issue information :param key: The JIRA Issue key (eg. JIRA-1234) :return: dict """ url = self.jira_domain + self.jira_issue_path + key auth = 'Basic ' + b64encode(self.jira_user + ':' + self.jira_password) return self._make_request(url, headers={ 'Authorization': auth, 'Content-Type': 'application/json' }) def send_message(self, issue, channel): """ Sends a Slack message with information about the JIRA Issue :param issue: The JIRA Issue dict :param channel: The Slack channel to send the message """ # Ensure there is a '#' prepended to the Slack channel channel = '#' + channel # The color of the post, to match the issue type color = self.colors.get(issue['fields']['issuetype']['name'], self.colors['DEFAULT']) # The Title to the JIRA issue, (eg. JIRA-1234 - Add JIRA Slack Integration) title = issue['key'] + ' - ' + issue['fields']['summary'] # The link to the JIRA issue show page title_link = self.jira_domain + '/browse/' + issue['key'] # Text sent to Slack, replaces JIRA code blocks with Slack code blocks # As a side effect this also replaces color blocks with Slack code blocks text = re.sub('{.*}', '```', issue['fields']['description']) # The priority name of the issue priority = issue['fields']['priority']['name'] # The name of the person assigned to the issue assignee = issue['fields']['assignee']['displayName'] # The status of the issue status = issue['fields']['status']['name'] # create the body of the request. body = json.dumps({ 'channel': channel, 'username': self.slack_user, 'icon_url': self.slack_user_icon, 'attachments': [ { 'fallback': title, 'mrkdwn_in': ['text', 'pretext', 'fields'], 'color': color, 'title': title, 'title_link': title_link, 'text': text, 'fields': [ {'title': 'Priority', 'value': priority, 'short': True}, {'title': 'Assignee', 'value': assignee, 'short': True}, {'title': 'Status', 'value': status, 'short': True}, ] } ] }) self._make_request(self.slack_webhook_url, body) def lambda_handler(event, _): """ Main entry point for AWS Lambda. Variables can not be passed in to AWS Lambda, the configuration parameters below are encrypted using AWS IAM Keys. :param event: The event as it is received from AWS Lambda """ # Boto is always available in AWS lambda, but may not be available in # standalone mode import boto3 # To generate the encrypted values, go to AWS IAM Keys and Generate a key # Then grant decryption using the key to the IAM Role used for your lambda # function. # # Then use the command `aws kms encrypt --key-id alias/ --plaintext # Put the encrypted value in the configuration dictionary below encrypted_config = { 'jira_domain': '', 'jira_user': '', 'jira_password': '', 'slack_webhook_url': '' } kms = boto3.client('kms') config = {x: kms.decrypt(CiphertextBlob=b64decode(y))['Plaintext'] for x, y in encrypted_config.iteritems()} bot = SlackJiraLinkBot(**config) return bot.on_message(event) def main(): """ Runs the SlackJIRA bot as a standalone server. """ from argparse import ArgumentParser from flask import Flask, request parser = ArgumentParser(usage=main.__doc__) parser.add_argument("-p", "--port", dest="port", default=8675, help="Port to run bot server") parser.add_argument("-jd", "--jira-domain", required=True, dest="jira_domain", help="Domain where your JIRA is located") parser.add_argument("-ju", "--jira-user", required=True, dest="jira_user", help="The JIRA username for authenticating to the JIRA REST API") parser.add_argument("-jp", "--jira-password", required=True, dest="jira_password", help="The JIRA password for authenticating to the JIRA REST API") parser.add_argument("-su", "--slack-webhook-url", dest="slack_webhook_url", help="URL for incoming Slack WebHook") app = Flask(__name__) app.config['PROPAGATE_EXCEPTIONS'] = True args = vars(parser.parse_args()) port = args.pop('port') bot = SlackJiraLinkBot(**args) log.addHandler(logging.StreamHandler()) @app.route('/', methods=('POST',)) def handle(): bot.on_message(request.form.to_dict()) return 'OK' app.run(port=port) if __name__ == '__main__': main()