2016-06-05 23:43:45 +02:00
|
|
|
"""Webhooks for external integrations."""
|
2024-01-29 00:32:21 +01:00
|
|
|
|
2017-11-16 00:43:10 +01:00
|
|
|
import re
|
2018-05-10 19:34:01 +02:00
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
2017-11-16 00:43:10 +01:00
|
|
|
|
2020-08-07 01:09:47 +02:00
|
|
|
import orjson
|
2017-11-16 00:43:10 +01:00
|
|
|
from defusedxml.ElementTree import fromstring as xml_fromstring
|
2016-06-05 23:43:45 +02:00
|
|
|
from django.http import HttpRequest, HttpResponse
|
2021-04-16 00:57:30 +02:00
|
|
|
from django.utils.translation import gettext as _
|
2016-05-25 15:02:02 +02:00
|
|
|
|
2020-08-20 00:32:15 +02:00
|
|
|
from zerver.decorator import webhook_view
|
2022-11-17 09:30:48 +01:00
|
|
|
from zerver.lib.exceptions import JsonableError, UnsupportedWebhookEventTypeError
|
2021-06-30 18:35:50 +02:00
|
|
|
from zerver.lib.response import json_success
|
2024-06-28 21:02:09 +02:00
|
|
|
from zerver.lib.typed_endpoint import typed_endpoint_without_parameters
|
2020-08-19 22:14:40 +02:00
|
|
|
from zerver.lib.webhooks.common import check_send_webhook_message
|
2017-05-02 01:00:50 +02:00
|
|
|
from zerver.models import UserProfile
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2020-01-14 22:06:24 +01:00
|
|
|
|
2021-07-16 11:40:46 +02:00
|
|
|
def api_pivotal_webhook_v3(request: HttpRequest, user_profile: UserProfile) -> Tuple[str, str, str]:
|
2016-03-13 13:03:28 +01:00
|
|
|
payload = xml_fromstring(request.body)
|
|
|
|
|
2017-11-04 07:47:46 +01:00
|
|
|
def get_text(attrs: List[str]) -> str:
|
2016-03-13 13:03:28 +01:00
|
|
|
start = payload
|
|
|
|
try:
|
|
|
|
for attr in attrs:
|
|
|
|
start = start.find(attr)
|
|
|
|
return start.text
|
|
|
|
except AttributeError:
|
|
|
|
return ""
|
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
event_type = payload.find("event_type").text
|
|
|
|
description = payload.find("description").text
|
|
|
|
project_id = payload.find("project_id").text
|
|
|
|
story_id = get_text(["stories", "story", "id"])
|
2020-10-23 02:43:28 +02:00
|
|
|
# Ugh, the URL in the XML data is not a clickable URL that works for the user
|
2016-03-13 13:03:28 +01:00
|
|
|
# so we try to build one that the user can actually click on
|
2020-06-10 06:41:04 +02:00
|
|
|
url = f"https://www.pivotaltracker.com/s/projects/{project_id}/stories/{story_id}"
|
2016-03-13 13:03:28 +01:00
|
|
|
|
|
|
|
# Pivotal doesn't tell us the name of the story, but it's usually in the
|
|
|
|
# description in quotes as the first quoted string
|
|
|
|
name_re = re.compile(r'[^"]+"([^"]+)".*')
|
|
|
|
match = name_re.match(description)
|
|
|
|
if match and len(match.groups()):
|
|
|
|
name = match.group(1)
|
|
|
|
else:
|
2017-05-07 20:10:03 +02:00
|
|
|
name = "Story changed" # Failed for an unknown reason, show something
|
2020-06-10 06:41:04 +02:00
|
|
|
more_info = f" [(view)]({url})."
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2021-02-12 08:20:45 +01:00
|
|
|
if event_type == "story_update":
|
2024-01-17 15:53:30 +01:00
|
|
|
topic_name = name
|
2016-03-13 13:03:28 +01:00
|
|
|
content = description + more_info
|
2021-02-12 08:20:45 +01:00
|
|
|
elif event_type == "note_create":
|
2024-01-17 15:53:30 +01:00
|
|
|
topic_name = "Comment added"
|
2016-11-30 22:49:02 +01:00
|
|
|
content = description + more_info
|
2021-02-12 08:20:45 +01:00
|
|
|
elif event_type == "story_create":
|
|
|
|
issue_desc = get_text(["stories", "story", "description"])
|
|
|
|
issue_type = get_text(["stories", "story", "story_type"])
|
|
|
|
issue_status = get_text(["stories", "story", "current_state"])
|
|
|
|
estimate = get_text(["stories", "story", "estimate"])
|
|
|
|
if estimate != "":
|
2020-06-10 06:41:04 +02:00
|
|
|
estimate = f" worth {estimate} story points"
|
2024-01-17 15:53:30 +01:00
|
|
|
topic_name = name
|
2020-06-10 06:41:04 +02:00
|
|
|
content = f"{description} ({issue_status} {issue_type}{estimate}):\n\n~~~ quote\n{issue_desc}\n~~~\n\n{more_info}"
|
2024-01-17 15:53:30 +01:00
|
|
|
return topic_name, content, f"{event_type}_v3"
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2018-04-27 22:54:05 +02:00
|
|
|
UNSUPPORTED_EVENT_TYPES = [
|
|
|
|
"task_create_activity",
|
|
|
|
"comment_delete_activity",
|
|
|
|
"task_delete_activity",
|
|
|
|
"task_update_activity",
|
|
|
|
"story_move_from_project_activity",
|
|
|
|
"story_delete_activity",
|
|
|
|
"story_move_into_project_activity",
|
|
|
|
"epic_update_activity",
|
2021-05-25 23:28:12 +02:00
|
|
|
"label_create_activity",
|
2018-04-27 22:54:05 +02:00
|
|
|
]
|
|
|
|
|
2021-07-16 11:40:46 +02:00
|
|
|
ALL_EVENT_TYPES = [
|
|
|
|
"story_update_v3",
|
|
|
|
"note_create_v3",
|
|
|
|
"story_create_v3",
|
|
|
|
"story_move_activity_v5",
|
|
|
|
"story_create_activity_v5",
|
|
|
|
"story_update_activity_v5",
|
|
|
|
"comment_create_activity_v5",
|
|
|
|
]
|
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-07-16 11:40:46 +02:00
|
|
|
def api_pivotal_webhook_v5(request: HttpRequest, user_profile: UserProfile) -> Tuple[str, str, str]:
|
2020-08-07 01:09:47 +02:00
|
|
|
payload = orjson.loads(request.body)
|
2016-03-13 13:03:28 +01:00
|
|
|
|
|
|
|
event_type = payload["kind"]
|
|
|
|
|
|
|
|
project_name = payload["project"]["name"]
|
|
|
|
project_id = payload["project"]["id"]
|
|
|
|
|
|
|
|
primary_resources = payload["primary_resources"][0]
|
|
|
|
story_url = primary_resources["url"]
|
2018-04-27 22:54:05 +02:00
|
|
|
story_type = primary_resources.get("story_type")
|
2016-03-13 13:03:28 +01:00
|
|
|
story_id = primary_resources["id"]
|
|
|
|
story_name = primary_resources["name"]
|
|
|
|
|
|
|
|
performed_by = payload.get("performed_by", {}).get("name", "")
|
|
|
|
|
2020-06-10 06:41:04 +02:00
|
|
|
story_info = f"[{project_name}](https://www.pivotaltracker.com/s/projects/{project_id}): [{story_name}]({story_url})"
|
2016-03-13 13:03:28 +01:00
|
|
|
|
|
|
|
changes = payload.get("changes", [])
|
|
|
|
|
|
|
|
content = ""
|
2024-01-17 15:53:30 +01:00
|
|
|
topic_name = f"#{story_id}: {story_name}"
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2018-05-10 19:34:01 +02:00
|
|
|
def extract_comment(change: Dict[str, Any]) -> Optional[str]:
|
2016-03-13 13:03:28 +01:00
|
|
|
if change.get("kind") == "comment":
|
|
|
|
return change.get("new_values", {}).get("text", None)
|
|
|
|
return None
|
|
|
|
|
|
|
|
if event_type == "story_update_activity":
|
|
|
|
# Find the changed valued and build a message
|
2020-06-10 06:41:04 +02:00
|
|
|
content += f"{performed_by} updated {story_info}:\n"
|
2016-03-13 13:03:28 +01:00
|
|
|
for change in changes:
|
|
|
|
old_values = change.get("original_values", {})
|
|
|
|
new_values = change["new_values"]
|
|
|
|
|
|
|
|
if "current_state" in old_values and "current_state" in new_values:
|
2020-06-10 06:41:04 +02:00
|
|
|
content += "* state changed from **{}** to **{}**\n".format(
|
2021-02-12 08:19:30 +01:00
|
|
|
old_values["current_state"], new_values["current_state"]
|
|
|
|
)
|
2016-03-13 13:03:28 +01:00
|
|
|
if "estimate" in old_values and "estimate" in new_values:
|
|
|
|
old_estimate = old_values.get("estimate", None)
|
|
|
|
if old_estimate is None:
|
|
|
|
estimate = "is now"
|
|
|
|
else:
|
2020-06-10 06:41:04 +02:00
|
|
|
estimate = f"changed from {old_estimate} to"
|
2016-03-13 13:03:28 +01:00
|
|
|
new_estimate = new_values["estimate"] if new_values["estimate"] is not None else "0"
|
2020-06-10 06:41:04 +02:00
|
|
|
content += f"* estimate {estimate} **{new_estimate} points**\n"
|
2016-03-13 13:03:28 +01:00
|
|
|
if "story_type" in old_values and "story_type" in new_values:
|
2020-06-10 06:41:04 +02:00
|
|
|
content += "* type changed from **{}** to **{}**\n".format(
|
2021-02-12 08:19:30 +01:00
|
|
|
old_values["story_type"], new_values["story_type"]
|
|
|
|
)
|
2016-03-13 13:03:28 +01:00
|
|
|
|
|
|
|
comment = extract_comment(change)
|
|
|
|
if comment is not None:
|
2020-06-10 06:41:04 +02:00
|
|
|
content += f"* Comment added:\n~~~quote\n{comment}\n~~~\n"
|
2016-03-13 13:03:28 +01:00
|
|
|
|
|
|
|
elif event_type == "comment_create_activity":
|
|
|
|
for change in changes:
|
|
|
|
comment = extract_comment(change)
|
|
|
|
if comment is not None:
|
2021-02-12 08:19:30 +01:00
|
|
|
content += (
|
|
|
|
f"{performed_by} added a comment to {story_info}:\n~~~quote\n{comment}\n~~~"
|
|
|
|
)
|
2016-03-13 13:03:28 +01:00
|
|
|
elif event_type == "story_create_activity":
|
2020-06-10 06:41:04 +02:00
|
|
|
content += f"{performed_by} created {story_type}: {story_info}\n"
|
2016-03-13 13:03:28 +01:00
|
|
|
for change in changes:
|
|
|
|
new_values = change.get("new_values", {})
|
|
|
|
if "current_state" in new_values:
|
2020-06-10 06:41:04 +02:00
|
|
|
content += "* State is **{}**\n".format(new_values["current_state"])
|
2016-03-13 13:03:28 +01:00
|
|
|
if "description" in new_values:
|
2020-06-10 06:41:04 +02:00
|
|
|
content += "* Description is\n\n> {}".format(new_values["description"])
|
2016-03-13 13:03:28 +01:00
|
|
|
elif event_type == "story_move_activity":
|
2020-06-10 06:41:04 +02:00
|
|
|
content = f"{performed_by} moved {story_info}"
|
2016-03-13 13:03:28 +01:00
|
|
|
for change in changes:
|
|
|
|
old_values = change.get("original_values", {})
|
|
|
|
new_values = change["new_values"]
|
|
|
|
if "current_state" in old_values and "current_state" in new_values:
|
2021-02-12 08:19:30 +01:00
|
|
|
content += " from **{}** to **{}**.".format(
|
|
|
|
old_values["current_state"], new_values["current_state"]
|
|
|
|
)
|
2018-04-27 22:54:05 +02:00
|
|
|
elif event_type in UNSUPPORTED_EVENT_TYPES:
|
2016-03-13 13:03:28 +01:00
|
|
|
# Known but unsupported Pivotal event types
|
|
|
|
pass
|
|
|
|
else:
|
2022-11-17 09:30:48 +01:00
|
|
|
raise UnsupportedWebhookEventTypeError(event_type)
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2024-01-17 15:53:30 +01:00
|
|
|
return topic_name, content, f"{event_type}_v5"
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2021-02-12 08:19:30 +01:00
|
|
|
|
2021-07-16 11:40:46 +02:00
|
|
|
@webhook_view("Pivotal", all_event_types=ALL_EVENT_TYPES)
|
2024-06-28 21:02:09 +02:00
|
|
|
@typed_endpoint_without_parameters
|
2018-03-16 22:53:50 +01:00
|
|
|
def api_pivotal_webhook(request: HttpRequest, user_profile: UserProfile) -> HttpResponse:
|
2024-01-17 15:53:30 +01:00
|
|
|
topic_name = content = None
|
2016-03-13 13:03:28 +01:00
|
|
|
try:
|
2024-01-17 15:53:30 +01:00
|
|
|
topic_name, content, event_type = api_pivotal_webhook_v3(request, user_profile)
|
2017-03-05 10:25:27 +01:00
|
|
|
except Exception:
|
2016-03-13 13:03:28 +01:00
|
|
|
# Attempt to parse v5 JSON payload
|
2024-01-17 15:53:30 +01:00
|
|
|
topic_name, content, event_type = api_pivotal_webhook_v5(request, user_profile)
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2019-07-31 21:25:34 +02:00
|
|
|
if not content:
|
2021-06-30 18:35:50 +02:00
|
|
|
raise JsonableError(_("Unable to handle Pivotal payload"))
|
2016-03-13 13:03:28 +01:00
|
|
|
|
2024-01-17 15:53:30 +01:00
|
|
|
check_send_webhook_message(request, user_profile, topic_name, content, event_type)
|
2022-01-31 13:44:02 +01:00
|
|
|
return json_success(request)
|