2016-03-13 12:50:20 +01:00
|
|
|
# Webhooks for external integrations.
|
2017-11-16 00:43:10 +01:00
|
|
|
import logging
|
|
|
|
import re
|
2018-05-10 19:34:01 +02:00
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
2016-05-25 15:02:02 +02:00
|
|
|
|
2017-11-16 00:43:10 +01:00
|
|
|
import ujson
|
2016-03-13 12:50:20 +01:00
|
|
|
from django.conf import settings
|
2017-11-16 00:43:10 +01:00
|
|
|
from django.db.models import Q
|
2016-06-05 23:09:32 +02:00
|
|
|
from django.http import HttpRequest, HttpResponse
|
2017-11-16 00:43:10 +01:00
|
|
|
from django.utils.translation import ugettext as _
|
2016-03-13 12:50:20 +01:00
|
|
|
|
2017-10-31 04:25:48 +01:00
|
|
|
from zerver.decorator import api_key_only_webhook_view
|
|
|
|
from zerver.lib.request import REQ, has_request_variables
|
2017-11-16 00:43:10 +01:00
|
|
|
from zerver.lib.response import json_error, json_success
|
2018-05-22 16:46:45 +02:00
|
|
|
from zerver.lib.webhooks.common import check_send_webhook_message, \
|
|
|
|
UnexpectedWebhookEventType
|
2018-12-07 00:05:57 +01:00
|
|
|
from zerver.models import Realm, UserProfile, get_user_by_delivery_email
|
2017-01-03 18:44:13 +01:00
|
|
|
|
|
|
|
IGNORED_EVENTS = [
|
|
|
|
'comment_created', # we handle issue_update event instead
|
|
|
|
'comment_deleted', # we handle issue_update event instead
|
2019-02-02 19:23:15 +01:00
|
|
|
'issuelink_created',
|
2017-01-03 18:44:13 +01:00
|
|
|
]
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def guess_zulip_user_from_jira(jira_username: str, realm: Realm) -> Optional[UserProfile]:
|
2016-03-13 12:50:20 +01:00
|
|
|
try:
|
|
|
|
# Try to find a matching user in Zulip
|
|
|
|
# We search a user's full name, short name,
|
|
|
|
# and beginning of email address
|
|
|
|
user = UserProfile.objects.filter(
|
2017-01-24 07:06:13 +01:00
|
|
|
Q(full_name__iexact=jira_username) |
|
|
|
|
Q(short_name__iexact=jira_username) |
|
|
|
|
Q(email__istartswith=jira_username),
|
|
|
|
is_active=True,
|
|
|
|
realm=realm).order_by("id")[0]
|
2016-03-13 12:50:20 +01:00
|
|
|
return user
|
|
|
|
except IndexError:
|
|
|
|
return None
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def convert_jira_markup(content: str, realm: Realm) -> str:
|
2016-03-13 12:50:20 +01:00
|
|
|
# Attempt to do some simplistic conversion of JIRA
|
|
|
|
# formatting to Markdown, for consumption in Zulip
|
|
|
|
|
|
|
|
# Jira uses *word* for bold, we use **word**
|
|
|
|
content = re.sub(r'\*([^\*]+)\*', r'**\1**', content)
|
|
|
|
|
|
|
|
# Jira uses {{word}} for monospacing, we use `word`
|
|
|
|
content = re.sub(r'{{([^\*]+?)}}', r'`\1`', content)
|
|
|
|
|
|
|
|
# Starting a line with bq. block quotes that line
|
|
|
|
content = re.sub(r'bq\. (.*)', r'> \1', content)
|
|
|
|
|
|
|
|
# Wrapping a block of code in {quote}stuff{quote} also block-quotes it
|
|
|
|
quote_re = re.compile(r'{quote}(.*?){quote}', re.DOTALL)
|
2016-10-17 08:22:00 +02:00
|
|
|
content = re.sub(quote_re, r'~~~ quote\n\1\n~~~', content)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
|
|
|
# {noformat}stuff{noformat} blocks are just code blocks with no
|
|
|
|
# syntax highlighting
|
|
|
|
noformat_re = re.compile(r'{noformat}(.*?){noformat}', re.DOTALL)
|
2016-10-17 08:22:00 +02:00
|
|
|
content = re.sub(noformat_re, r'~~~\n\1\n~~~', content)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
|
|
|
# Code blocks are delineated by {code[: lang]} {code}
|
|
|
|
code_re = re.compile(r'{code[^\n]*}(.*?){code}', re.DOTALL)
|
2016-10-17 08:22:00 +02:00
|
|
|
content = re.sub(code_re, r'~~~\n\1\n~~~', content)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
|
|
|
# Links are of form: [https://www.google.com] or [Link Title|https://www.google.com]
|
|
|
|
# In order to support both forms, we don't match a | in bare links
|
|
|
|
content = re.sub(r'\[([^\|~]+?)\]', r'[\1](\1)', content)
|
|
|
|
|
|
|
|
# Full links which have a | are converted into a better markdown link
|
|
|
|
full_link_re = re.compile(r'\[(?:(?P<title>[^|~]+)\|)(?P<url>.*)\]')
|
2016-10-17 08:22:00 +02:00
|
|
|
content = re.sub(full_link_re, r'[\g<title>](\g<url>)', content)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
|
|
|
# Try to convert a JIRA user mention of format [~username] into a
|
|
|
|
# Zulip user mention. We don't know the email, just the JIRA username,
|
|
|
|
# so we naively guess at their Zulip account using this
|
|
|
|
if realm:
|
2018-07-02 00:05:24 +02:00
|
|
|
mention_re = re.compile(u'\\[~(.*?)\\]')
|
2016-03-13 12:50:20 +01:00
|
|
|
for username in mention_re.findall(content):
|
|
|
|
# Try to look up username
|
|
|
|
user_profile = guess_zulip_user_from_jira(username, realm)
|
|
|
|
if user_profile:
|
2017-03-15 21:23:51 +01:00
|
|
|
replacement = u"**{}**".format(user_profile.full_name)
|
2016-03-13 12:50:20 +01:00
|
|
|
else:
|
2017-03-15 21:23:51 +01:00
|
|
|
replacement = u"**{}**".format(username)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
2016-12-31 15:57:50 +01:00
|
|
|
content = content.replace("[~{}]".format(username,), replacement)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
|
|
|
return content
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_in(payload: Dict[str, Any], keys: List[str], default: str='') -> Any:
|
2016-12-31 15:57:50 +01:00
|
|
|
try:
|
|
|
|
for key in keys:
|
|
|
|
payload = payload[key]
|
|
|
|
except (AttributeError, KeyError, TypeError):
|
|
|
|
return default
|
|
|
|
return payload
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_issue_string(payload: Dict[str, Any], issue_id: Optional[str]=None) -> str:
|
2016-03-13 12:50:20 +01:00
|
|
|
# Guess the URL as it is not specified in the payload
|
|
|
|
# We assume that there is a /browse/BUG-### page
|
|
|
|
# from the REST url of the issue itself
|
2016-12-31 15:57:50 +01:00
|
|
|
if issue_id is None:
|
|
|
|
issue_id = get_issue_id(payload)
|
|
|
|
|
2018-07-02 00:05:24 +02:00
|
|
|
base_url = re.match(r"(.*)\/rest\/api/.*", get_in(payload, ['issue', 'self']))
|
2016-12-31 15:57:50 +01:00
|
|
|
if base_url and len(base_url.groups()):
|
2017-03-15 21:23:51 +01:00
|
|
|
return u"[{}]({}/browse/{})".format(issue_id, base_url.group(1), issue_id)
|
2016-03-13 12:50:20 +01:00
|
|
|
else:
|
2016-12-31 15:57:50 +01:00
|
|
|
return issue_id
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_assignee_mention(assignee_email: str, realm: Realm) -> str:
|
2016-03-13 12:50:20 +01:00
|
|
|
if assignee_email != '':
|
|
|
|
try:
|
2018-12-07 00:05:57 +01:00
|
|
|
assignee_name = get_user_by_delivery_email(assignee_email, realm).full_name
|
2016-03-13 12:50:20 +01:00
|
|
|
except UserProfile.DoesNotExist:
|
2016-12-31 15:57:50 +01:00
|
|
|
assignee_name = assignee_email
|
2017-03-15 21:23:51 +01:00
|
|
|
return u"**{}**".format(assignee_name)
|
2016-12-31 15:57:50 +01:00
|
|
|
return ''
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_issue_author(payload: Dict[str, Any]) -> str:
|
2016-12-31 15:57:50 +01:00
|
|
|
return get_in(payload, ['user', 'displayName'])
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_issue_id(payload: Dict[str, Any]) -> str:
|
2016-12-31 15:57:50 +01:00
|
|
|
return get_in(payload, ['issue', 'key'])
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_issue_title(payload: Dict[str, Any]) -> str:
|
2016-12-31 15:57:50 +01:00
|
|
|
return get_in(payload, ['issue', 'fields', 'summary'])
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_issue_subject(payload: Dict[str, Any]) -> str:
|
2017-03-15 21:23:51 +01:00
|
|
|
return u"{}: {}".format(get_issue_id(payload), get_issue_title(payload))
|
2016-12-31 15:57:50 +01:00
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_sub_event_for_update_issue(payload: Dict[str, Any]) -> str:
|
2017-01-30 19:26:48 +01:00
|
|
|
sub_event = payload.get('issue_event_type_name', '')
|
|
|
|
if sub_event == '':
|
|
|
|
if payload.get('comment'):
|
|
|
|
return 'issue_commented'
|
|
|
|
elif payload.get('transition'):
|
|
|
|
return 'issue_transited'
|
|
|
|
return sub_event
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def get_event_type(payload: Dict[str, Any]) -> Optional[str]:
|
2017-01-30 19:26:48 +01:00
|
|
|
event = payload.get('webhookEvent')
|
|
|
|
if event is None and payload.get('transition'):
|
|
|
|
event = 'jira:issue_updated'
|
|
|
|
return event
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def add_change_info(content: str, field: str, from_field: str, to_field: str) -> str:
|
2017-03-15 21:23:51 +01:00
|
|
|
content += u"* Changed {}".format(field)
|
2017-01-30 19:26:48 +01:00
|
|
|
if from_field:
|
2017-03-15 21:23:51 +01:00
|
|
|
content += u" from **{}**".format(from_field)
|
2017-01-30 19:26:48 +01:00
|
|
|
if to_field:
|
2017-03-15 21:23:51 +01:00
|
|
|
content += u" to {}\n".format(to_field)
|
2017-01-30 19:26:48 +01:00
|
|
|
return content
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def handle_updated_issue_event(payload: Dict[str, Any], user_profile: UserProfile) -> str:
|
2016-12-31 15:57:50 +01:00
|
|
|
# Reassigned, commented, reopened, and resolved events are all bundled
|
|
|
|
# into this one 'updated' event type, so we try to extract the meaningful
|
|
|
|
# event that happened
|
|
|
|
issue_id = get_in(payload, ['issue', 'key'])
|
|
|
|
issue = get_issue_string(payload, issue_id)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
2016-12-31 15:57:50 +01:00
|
|
|
assignee_email = get_in(payload, ['issue', 'fields', 'assignee', 'emailAddress'], '')
|
2017-07-14 01:29:03 +02:00
|
|
|
assignee_mention = get_assignee_mention(assignee_email, user_profile.realm)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
2016-12-31 15:57:50 +01:00
|
|
|
if assignee_mention != '':
|
2017-03-15 21:23:51 +01:00
|
|
|
assignee_blurb = u" (assigned to {})".format(assignee_mention)
|
2016-12-31 15:57:50 +01:00
|
|
|
else:
|
|
|
|
assignee_blurb = ''
|
|
|
|
|
2017-01-30 19:26:48 +01:00
|
|
|
sub_event = get_sub_event_for_update_issue(payload)
|
2017-01-03 18:44:13 +01:00
|
|
|
if 'comment' in sub_event:
|
|
|
|
if sub_event == 'issue_commented':
|
|
|
|
verb = 'added comment to'
|
|
|
|
elif sub_event == 'issue_comment_edited':
|
|
|
|
verb = 'edited comment on'
|
|
|
|
else:
|
|
|
|
verb = 'deleted comment from'
|
2019-01-07 21:58:39 +01:00
|
|
|
|
|
|
|
if payload.get('webhookEvent') == 'comment_created':
|
|
|
|
author = payload['comment']['author']['displayName']
|
|
|
|
else:
|
|
|
|
author = get_issue_author(payload)
|
|
|
|
|
|
|
|
content = u"{} **{}** {}{}".format(author, verb, issue, assignee_blurb)
|
2017-01-03 18:44:13 +01:00
|
|
|
comment = get_in(payload, ['comment', 'body'])
|
|
|
|
if comment:
|
|
|
|
comment = convert_jira_markup(comment, user_profile.realm)
|
2017-01-30 19:26:48 +01:00
|
|
|
content = u"{}:\n\n\n{}\n".format(content, comment)
|
2017-01-03 18:44:13 +01:00
|
|
|
else:
|
2017-01-30 19:26:48 +01:00
|
|
|
content = u"{} **updated** {}{}:\n\n".format(get_issue_author(payload), issue, assignee_blurb)
|
2017-01-03 18:44:13 +01:00
|
|
|
changelog = get_in(payload, ['changelog'])
|
2016-03-13 12:50:20 +01:00
|
|
|
|
2017-01-03 18:44:13 +01:00
|
|
|
if changelog != '':
|
|
|
|
# Use the changelog to display the changes, whitelist types we accept
|
|
|
|
items = changelog.get('items')
|
|
|
|
for item in items:
|
|
|
|
field = item.get('field')
|
2016-03-13 12:50:20 +01:00
|
|
|
|
2017-01-03 18:44:13 +01:00
|
|
|
if field == 'assignee' and assignee_mention != '':
|
|
|
|
target_field_string = assignee_mention
|
|
|
|
else:
|
|
|
|
# Convert a user's target to a @-mention if possible
|
2017-03-15 21:23:51 +01:00
|
|
|
target_field_string = u"**{}**".format(item.get('toString'))
|
2016-12-31 15:57:50 +01:00
|
|
|
|
2017-01-03 18:44:13 +01:00
|
|
|
from_field_string = item.get('fromString')
|
|
|
|
if target_field_string or from_field_string:
|
2017-01-30 19:26:48 +01:00
|
|
|
content = add_change_info(content, field, from_field_string, target_field_string)
|
|
|
|
|
|
|
|
elif sub_event == 'issue_transited':
|
|
|
|
from_field_string = get_in(payload, ['transition', 'from_status'])
|
2017-03-15 21:23:51 +01:00
|
|
|
target_field_string = u'**{}**'.format(get_in(payload, ['transition', 'to_status']))
|
2017-01-30 19:26:48 +01:00
|
|
|
if target_field_string or from_field_string:
|
|
|
|
content = add_change_info(content, 'status', from_field_string, target_field_string)
|
2016-12-31 15:57:50 +01:00
|
|
|
|
|
|
|
return content
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def handle_created_issue_event(payload: Dict[str, Any]) -> str:
|
2017-03-15 21:23:51 +01:00
|
|
|
return u"{} **created** {} priority {}, assigned to **{}**:\n\n> {}".format(
|
2016-12-31 15:57:50 +01:00
|
|
|
get_issue_author(payload),
|
|
|
|
get_issue_string(payload),
|
|
|
|
get_in(payload, ['issue', 'fields', 'priority', 'name']),
|
|
|
|
get_in(payload, ['issue', 'fields', 'assignee', 'displayName'], 'no one'),
|
|
|
|
get_issue_title(payload)
|
|
|
|
)
|
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def handle_deleted_issue_event(payload: Dict[str, Any]) -> str:
|
2017-03-15 21:23:51 +01:00
|
|
|
return u"{} **deleted** {}!".format(get_issue_author(payload), get_issue_string(payload))
|
2016-12-31 15:57:50 +01:00
|
|
|
|
|
|
|
@api_key_only_webhook_view("JIRA")
|
|
|
|
@has_request_variables
|
2017-12-06 19:38:19 +01:00
|
|
|
def api_jira_webhook(request: HttpRequest, user_profile: UserProfile,
|
2018-03-16 22:53:50 +01:00
|
|
|
payload: Dict[str, Any]=REQ(argument_type='body')) -> HttpResponse:
|
2016-12-31 15:57:50 +01:00
|
|
|
|
2017-01-30 19:26:48 +01:00
|
|
|
event = get_event_type(payload)
|
2016-12-31 15:57:50 +01:00
|
|
|
if event == 'jira:issue_created':
|
|
|
|
subject = get_issue_subject(payload)
|
|
|
|
content = handle_created_issue_event(payload)
|
|
|
|
elif event == 'jira:issue_deleted':
|
|
|
|
subject = get_issue_subject(payload)
|
|
|
|
content = handle_deleted_issue_event(payload)
|
|
|
|
elif event == 'jira:issue_updated':
|
|
|
|
subject = get_issue_subject(payload)
|
|
|
|
content = handle_updated_issue_event(payload, user_profile)
|
2019-01-07 21:58:39 +01:00
|
|
|
elif event == 'comment_created':
|
|
|
|
subject = get_issue_subject(payload)
|
|
|
|
content = handle_updated_issue_event(payload, user_profile)
|
2017-01-03 18:44:13 +01:00
|
|
|
elif event in IGNORED_EVENTS:
|
|
|
|
return json_success()
|
2016-03-13 12:50:20 +01:00
|
|
|
else:
|
2018-05-22 16:46:45 +02:00
|
|
|
raise UnexpectedWebhookEventType('Jira', event)
|
2016-03-13 12:50:20 +01:00
|
|
|
|
2018-11-06 17:07:04 +01:00
|
|
|
check_send_webhook_message(request, user_profile,
|
|
|
|
subject, content,
|
2018-12-04 01:59:39 +01:00
|
|
|
unquote_url_parameters=True)
|
2016-03-13 12:50:20 +01:00
|
|
|
return json_success()
|