Skip to content

Instantly share code, notes, and snippets.

@devdazed
Created December 28, 2015 19:35
Show Gist options
  • Save devdazed/07c3437fbc76c999af4d to your computer and use it in GitHub Desktop.
Save devdazed/07c3437fbc76c999af4d to your computer and use it in GitHub Desktop.

Revisions

  1. Russ Bradberry created this gist Dec 28, 2015.
    276 changes: 276 additions & 0 deletions jira-slack.py
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,276 @@
    #!/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/<key-alias> --plaintext <value-to-encrypt>
    # Put the encrypted value in the configuration dictionary below
    encrypted_config = {
    'jira_domain': '<ENCRYPTED JIRA DOMAIN>',
    'jira_user': '<ENCRYPTED JIRA USER>',
    'jira_password': '<ENCRYPTED JIRA PASSWORD>',
    'slack_webhook_url': '<ENCRYPTED 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()