zulip/zerver/webhooks/taiga/view.py

376 lines
16 KiB
Python
Raw Normal View History

"""Taiga integration for Zulip.
2016-04-28 15:46:00 +02:00
Tips for notification output:
*Text formatting*: if there has been a change of a property, the new
value should always be in bold; otherwise the subject of US/task
should be in bold.
2016-04-28 15:46:00 +02:00
"""
from typing import TypeAlias
2016-06-06 01:56:35 +02:00
from django.http import HttpRequest, HttpResponse
from zerver.decorator import webhook_view
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import JsonBodyPayload, typed_endpoint
from zerver.lib.validator import WildValue, check_bool, check_none_or, check_string
from zerver.lib.webhooks.common import check_send_webhook_message
from zerver.models import UserProfile
2016-04-28 15:46:00 +02:00
EventType: TypeAlias = dict[str, str | dict[str, str | bool | None]]
ReturnType: TypeAlias = tuple[WildValue, WildValue]
@webhook_view("Taiga")
@typed_endpoint
def api_taiga_webhook(
request: HttpRequest,
user_profile: UserProfile,
*,
message: JsonBodyPayload[WildValue],
) -> HttpResponse:
2016-04-28 15:46:00 +02:00
parsed_events = parse_message(message)
content = "".join(sorted(generate_content(event) + "\n" for event in parsed_events))
topic_name = "General"
if message["data"].get("milestone") and "name" in message["data"]["milestone"]:
topic_name = message["data"]["milestone"]["name"].tame(check_string)
check_send_webhook_message(request, user_profile, topic_name, content)
2016-04-28 15:46:00 +02:00
return json_success(request)
2016-04-28 15:46:00 +02:00
2016-04-28 15:46:00 +02:00
templates = {
"epic": {
"create": "[{user}]({user_link}) created epic {subject}.",
"set_assigned_to": "[{user}]({user_link}) assigned epic {subject} to {new}.",
"unset_assigned_to": "[{user}]({user_link}) unassigned epic {subject}.",
"changed_assigned_to": "[{user}]({user_link}) reassigned epic {subject}"
" from {old} to {new}.",
"blocked": "[{user}]({user_link}) blocked epic {subject}.",
"unblocked": "[{user}]({user_link}) unblocked epic {subject}.",
"changed_status": "[{user}]({user_link}) changed status of epic {subject}"
" from {old} to {new}.",
"renamed": "[{user}]({user_link}) renamed epic from **{old}** to **{new}**.",
"description_diff": "[{user}]({user_link}) updated description of epic {subject}.",
"commented": "[{user}]({user_link}) commented on epic {subject}.",
"delete": "[{user}]({user_link}) deleted epic {subject}.",
2017-10-22 05:30:45 +02:00
},
"relateduserstory": {
"create": (
"[{user}]({user_link}) added a related user story"
" {userstory_subject} to the epic {epic_subject}."
),
"delete": (
"[{user}]({user_link}) removed a related user story"
" {userstory_subject} from the epic {epic_subject}."
),
},
"userstory": {
"create": "[{user}]({user_link}) created user story {subject}.",
"set_assigned_to": "[{user}]({user_link}) assigned user story {subject} to {new}.",
"unset_assigned_to": "[{user}]({user_link}) unassigned user story {subject}.",
"changed_assigned_to": "[{user}]({user_link}) reassigned user story {subject}"
" from {old} to {new}.",
"points": "[{user}]({user_link}) changed estimation of user story {subject}.",
"blocked": "[{user}]({user_link}) blocked user story {subject}.",
"unblocked": "[{user}]({user_link}) unblocked user story {subject}.",
"set_milestone": "[{user}]({user_link}) added user story {subject} to sprint {new}.",
"unset_milestone": "[{user}]({user_link}) removed user story {subject} from sprint {old}.",
"changed_milestone": "[{user}]({user_link}) changed sprint of user story {subject} from {old}"
" to {new}.",
"changed_status": "[{user}]({user_link}) changed status of user story {subject}"
" from {old} to {new}.",
"closed": "[{user}]({user_link}) closed user story {subject}.",
"reopened": "[{user}]({user_link}) reopened user story {subject}.",
"renamed": "[{user}]({user_link}) renamed user story from {old} to **{new}**.",
"description_diff": "[{user}]({user_link}) updated description of user story {subject}.",
"commented": "[{user}]({user_link}) commented on user story {subject}.",
"delete": "[{user}]({user_link}) deleted user story {subject}.",
"due_date": "[{user}]({user_link}) changed due date of user story {subject}"
" from {old} to {new}.",
"set_due_date": "[{user}]({user_link}) set due date of user story {subject} to {new}.",
2016-04-28 15:46:00 +02:00
},
"milestone": {
"create": "[{user}]({user_link}) created sprint {subject}.",
"renamed": "[{user}]({user_link}) renamed sprint from {old} to **{new}**.",
"estimated_start": "[{user}]({user_link}) changed estimated start of sprint {subject}"
" from {old} to {new}.",
"estimated_finish": "[{user}]({user_link}) changed estimated finish of sprint {subject}"
" from {old} to {new}.",
"set_estimated_start": "[{user}]({user_link}) changed estimated start of sprint {subject}"
" to {new}.",
"set_estimated_finish": "[{user}]({user_link}) set estimated finish of sprint {subject}"
" to {new}.",
"delete": "[{user}]({user_link}) deleted sprint {subject}.",
2016-04-28 15:46:00 +02:00
},
"task": {
"create": "[{user}]({user_link}) created task {subject}.",
"set_assigned_to": "[{user}]({user_link}) assigned task {subject} to {new}.",
"unset_assigned_to": "[{user}]({user_link}) unassigned task {subject}.",
"changed_assigned_to": "[{user}]({user_link}) reassigned task {subject}"
" from {old} to {new}.",
"blocked": "[{user}]({user_link}) blocked task {subject}.",
"unblocked": "[{user}]({user_link}) unblocked task {subject}.",
"changed_status": "[{user}]({user_link}) changed status of task {subject}"
" from {old} to {new}.",
"renamed": "[{user}]({user_link}) renamed task {old} to **{new}**.",
"description_diff": "[{user}]({user_link}) updated description of task {subject}.",
"set_milestone": "[{user}]({user_link}) added task {subject} to sprint {new}.",
"commented": "[{user}]({user_link}) commented on task {subject}.",
"delete": "[{user}]({user_link}) deleted task {subject}.",
"changed_us": "[{user}]({user_link}) moved task {subject} from user story {old} to {new}.",
"due_date": "[{user}]({user_link}) changed due date of task {subject} from {old} to {new}.",
"set_due_date": "[{user}]({user_link}) set due date of task {subject} to {new}.",
2016-04-28 15:46:00 +02:00
},
"issue": {
"create": "[{user}]({user_link}) created issue {subject}.",
"set_assigned_to": "[{user}]({user_link}) assigned issue {subject} to {new}.",
"unset_assigned_to": "[{user}]({user_link}) unassigned issue {subject}.",
"changed_assigned_to": "[{user}]({user_link}) reassigned issue {subject}"
" from {old} to {new}.",
"set_milestone": "[{user}]({user_link}) added issue {subject} to sprint {new}.",
"unset_milestone": "[{user}]({user_link}) detached issue {subject} from sprint {old}.",
"changed_priority": "[{user}]({user_link}) changed priority of issue "
"{subject} from {old} to {new}.",
"changed_severity": "[{user}]({user_link}) changed severity of issue "
"{subject} from {old} to {new}.",
"changed_status": "[{user}]({user_link}) changed status of issue {subject}"
" from {old} to {new}.",
"changed_type": "[{user}]({user_link}) changed type of issue {subject} from {old} to {new}.",
"renamed": "[{user}]({user_link}) renamed issue {old} to **{new}**.",
"description_diff": "[{user}]({user_link}) updated description of issue {subject}.",
"commented": "[{user}]({user_link}) commented on issue {subject}.",
"delete": "[{user}]({user_link}) deleted issue {subject}.",
"due_date": "[{user}]({user_link}) changed due date of issue {subject}"
" from {old} to {new}.",
"set_due_date": "[{user}]({user_link}) set due date of issue {subject} to {new}.",
"blocked": "[{user}]({user_link}) blocked issue {subject}.",
"unblocked": "[{user}]({user_link}) unblocked issue {subject}.",
2016-04-28 15:46:00 +02:00
},
"webhook_test": {
"test": "[{user}]({user_link}) triggered a test of the Taiga integration.",
},
2016-04-28 15:46:00 +02:00
}
def get_old_and_new_values(change_type: str, message: WildValue) -> ReturnType:
"""Parses the payload and finds previous and current value of change_type."""
old = message["change"]["diff"][change_type].get("from")
new = message["change"]["diff"][change_type].get("to")
2016-04-28 15:46:00 +02:00
return old, new
def parse_comment(
message: WildValue,
) -> EventType:
"""Parses the comment to issue, task or US."""
2016-04-28 15:46:00 +02:00
return {
"event": "commented",
"type": message["type"].tame(check_string),
"values": {
"user": get_owner_name(message),
"user_link": get_owner_link(message),
"subject": get_subject(message),
},
2016-04-28 15:46:00 +02:00
}
def parse_create_or_delete(
message: WildValue,
) -> EventType:
"""Parses create or delete event."""
if message["type"].tame(check_string) == "relateduserstory":
return {
"type": message["type"].tame(check_string),
"event": message["action"].tame(check_string),
"values": {
"user": get_owner_name(message),
"user_link": get_owner_link(message),
"epic_subject": get_epic_subject(message),
"userstory_subject": get_userstory_subject(message),
},
}
2016-04-28 15:46:00 +02:00
return {
"type": message["type"].tame(check_string),
"event": message["action"].tame(check_string),
"values": {
"user": get_owner_name(message),
"user_link": get_owner_link(message),
"subject": get_subject(message),
},
2016-04-28 15:46:00 +02:00
}
def parse_change_event(change_type: str, message: WildValue) -> EventType | None:
"""Parses change event."""
evt: EventType = {}
values: dict[str, str | bool | None] = {
"user": get_owner_name(message),
"user_link": get_owner_link(message),
"subject": get_subject(message),
python: Convert assignment type annotations to Python 3.6 style. This commit was split by tabbott; this piece covers the vast majority of files in Zulip, but excludes scripts/, tools/, and puppet/ to help ensure we at least show the right error messages for Xenial systems. We can likely further refine the remaining pieces with some testing. Generated by com2ann, with whitespace fixes and various manual fixes for runtime issues: - invoiced_through: Optional[LicenseLedger] = models.ForeignKey( + invoiced_through: Optional["LicenseLedger"] = models.ForeignKey( -_apns_client: Optional[APNsClient] = None +_apns_client: Optional["APNsClient"] = None - notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE) - signup_notifications_stream: Optional[Stream] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE) + notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE) + signup_notifications_stream: Optional["Stream"] = models.ForeignKey('Stream', related_name='+', null=True, blank=True, on_delete=CASCADE) - author: Optional[UserProfile] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE) + author: Optional["UserProfile"] = models.ForeignKey('UserProfile', blank=True, null=True, on_delete=CASCADE) - bot_owner: Optional[UserProfile] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL) + bot_owner: Optional["UserProfile"] = models.ForeignKey('self', null=True, on_delete=models.SET_NULL) - default_sending_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE) - default_events_register_stream: Optional[Stream] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE) + default_sending_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE) + default_events_register_stream: Optional["Stream"] = models.ForeignKey('zerver.Stream', null=True, related_name='+', on_delete=CASCADE) -descriptors_by_handler_id: Dict[int, ClientDescriptor] = {} +descriptors_by_handler_id: Dict[int, "ClientDescriptor"] = {} -worker_classes: Dict[str, Type[QueueProcessingWorker]] = {} -queues: Dict[str, Dict[str, Type[QueueProcessingWorker]]] = {} +worker_classes: Dict[str, Type["QueueProcessingWorker"]] = {} +queues: Dict[str, Dict[str, Type["QueueProcessingWorker"]]] = {} -AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional[LDAPSearch] = None +AUTH_LDAP_REVERSE_EMAIL_SEARCH: Optional["LDAPSearch"] = None Signed-off-by: Anders Kaseorg <anders@zulipchat.com>
2020-04-22 01:09:50 +02:00
}
2016-04-28 15:46:00 +02:00
if change_type in ["description_diff", "points"]:
2016-04-28 15:46:00 +02:00
event_type = change_type
elif change_type in ["milestone", "assigned_to"]:
old, new = get_old_and_new_values(change_type, message)
tamed_old = old.tame(check_none_or(check_string))
tamed_new = new.tame(check_none_or(check_string))
if not tamed_old:
2016-04-28 15:46:00 +02:00
event_type = "set_" + change_type
values["new"] = tamed_new
elif not tamed_new:
event_type = "unset_" + change_type
values["old"] = tamed_old
2016-04-28 15:46:00 +02:00
else:
event_type = "changed_" + change_type
values.update(old=tamed_old, new=tamed_new)
2016-04-28 15:46:00 +02:00
elif change_type == "is_blocked":
if message["change"]["diff"]["is_blocked"]["to"].tame(check_bool):
2016-04-28 15:46:00 +02:00
event_type = "blocked"
else:
event_type = "unblocked"
elif change_type == "is_closed":
if message["change"]["diff"]["is_closed"]["to"].tame(check_bool):
2016-04-28 15:46:00 +02:00
event_type = "closed"
else:
event_type = "reopened"
elif change_type == "user_story":
old, new = get_old_and_new_values(change_type, message)
event_type = "changed_us"
tamed_old = old.tame(check_none_or(check_string))
tamed_new = new.tame(check_none_or(check_string))
values.update(old=tamed_old, new=tamed_new)
2016-04-28 15:46:00 +02:00
elif change_type in ["subject", "name"]:
event_type = "renamed"
2016-04-28 15:46:00 +02:00
old, new = get_old_and_new_values(change_type, message)
tamed_old = old.tame(check_none_or(check_string))
tamed_new = new.tame(check_none_or(check_string))
values.update(old=tamed_old, new=tamed_new)
2016-04-28 15:46:00 +02:00
elif change_type in ["estimated_finish", "estimated_start", "due_date"]:
2016-04-28 15:46:00 +02:00
old, new = get_old_and_new_values(change_type, message)
tamed_old = old.tame(check_none_or(check_string))
tamed_new = new.tame(check_none_or(check_string))
if not tamed_old:
event_type = "set_" + change_type
values["new"] = tamed_new
elif tamed_old != tamed_new:
2016-04-28 15:46:00 +02:00
event_type = change_type
values.update(old=tamed_old, new=tamed_new)
2016-04-28 15:46:00 +02:00
else:
# date hasn't changed
return None
elif change_type in ["priority", "severity", "type", "status"]:
event_type = "changed_" + change_type
2016-04-28 15:46:00 +02:00
old, new = get_old_and_new_values(change_type, message)
tamed_old = old.tame(check_none_or(check_string))
tamed_new = new.tame(check_none_or(check_string))
values.update(old=tamed_old, new=tamed_new)
2016-04-28 15:46:00 +02:00
else:
# we are not supporting this type of event
return None
evt.update(type=message["type"].tame(check_string), event=event_type, values=values)
2016-04-28 15:46:00 +02:00
return evt
def parse_webhook_test(
message: WildValue,
) -> EventType:
return {
"type": "webhook_test",
"event": "test",
"values": {
"user": get_owner_name(message),
"user_link": get_owner_link(message),
"end_type": "test",
},
}
2016-04-28 15:46:00 +02:00
def parse_message(
message: WildValue,
) -> list[EventType]:
"""Parses the payload by delegating to specialized functions."""
events: list[EventType] = []
if message["action"].tame(check_string) in ["create", "delete"]:
2016-04-28 15:46:00 +02:00
events.append(parse_create_or_delete(message))
elif message["action"].tame(check_string) == "change":
2016-04-28 15:46:00 +02:00
if message["change"]["diff"]:
for value in message["change"]["diff"].keys(): # noqa: SIM118
2016-04-28 15:46:00 +02:00
parsed_event = parse_change_event(value, message)
2016-11-30 21:45:02 +01:00
if parsed_event:
events.append(parsed_event)
if message["change"]["comment"].tame(check_string):
2016-04-28 15:46:00 +02:00
events.append(parse_comment(message))
elif message["action"].tame(check_string) == "test":
events.append(parse_webhook_test(message))
2016-04-28 15:46:00 +02:00
return events
def generate_content(data: EventType) -> str:
"""Gets the template string and formats it with parsed data."""
assert isinstance(data["type"], str) and isinstance(data["event"], str)
template = templates[data["type"]][data["event"]]
assert isinstance(data["values"], dict)
content = template.format(**data["values"])
return content
def get_owner_name(message: WildValue) -> str:
return message["by"]["full_name"].tame(check_string)
def get_owner_link(message: WildValue) -> str:
return message["by"]["permalink"].tame(check_string)
def get_subject(message: WildValue) -> str:
data = message["data"]
subject = data.get("subject").tame(check_none_or(check_string))
subject_to_use = subject if subject else data["name"].tame(check_string)
if "permalink" in data:
return "[" + subject_to_use + "]" + "(" + data["permalink"].tame(check_string) + ")"
return "**" + subject_to_use + "**"
def get_epic_subject(message: WildValue) -> str:
if "permalink" in message["data"]["epic"]:
return (
"["
+ message["data"]["epic"]["subject"].tame(check_string)
+ "]"
+ "("
+ message["data"]["epic"]["permalink"].tame(check_string)
+ ")"
)
return "**" + message["data"]["epic"]["subject"].tame(check_string) + "**"
def get_userstory_subject(message: WildValue) -> str:
if "permalink" in message["data"]["user_story"]:
us_data = message["data"]["user_story"]
return (
"["
+ us_data["subject"].tame(check_string)
+ "]"
+ "("
+ us_data["permalink"].tame(check_string)
+ ")"
)
return "**" + message["data"]["user_story"]["subject"].tame(check_string) + "**"