snapshot of https://github.com/zulip/zulip.git @ 48c69911c7dd4e03b20552eb3bd77ad8151c1357 (2026-09-15)
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
This commit is contained in:
287
zerver/webhooks/taiga/view.py
Normal file
287
zerver/webhooks/taiga/view.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""Taiga integration for Zulip.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from typing import TypedDict
|
||||
|
||||
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
|
||||
|
||||
|
||||
class EventType(TypedDict):
|
||||
type: str
|
||||
event: str
|
||||
values: dict[str, str | bool | None]
|
||||
|
||||
|
||||
@webhook_view("Taiga")
|
||||
@typed_endpoint
|
||||
def api_taiga_webhook(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
*,
|
||||
message: JsonBodyPayload[WildValue],
|
||||
) -> HttpResponse:
|
||||
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)
|
||||
|
||||
return json_success(request)
|
||||
|
||||
|
||||
TEMPLATES = {
|
||||
"epic": {
|
||||
"create": "{user} created epic {subject}.",
|
||||
"set_assigned_to": "{user} assigned epic {subject} to {new}.",
|
||||
"unset_assigned_to": "{user} unassigned epic {subject}.",
|
||||
"changed_assigned_to": "{user} reassigned epic {subject} from {old} to {new}.",
|
||||
"blocked": "{user} blocked epic {subject}.",
|
||||
"unblocked": "{user} unblocked epic {subject}.",
|
||||
"changed_status": "{user} changed status of epic {subject} from {old} to {new}.",
|
||||
"renamed": "{user} renamed epic from **{old}** to **{new}**.",
|
||||
"description_diff": "{user} updated description of epic {subject}.",
|
||||
"commented": "{user} commented on epic {subject}.",
|
||||
"delete": "{user} deleted epic {subject}.",
|
||||
},
|
||||
"relateduserstory": {
|
||||
"create": (
|
||||
"{user} added a related user story {userstory_subject} to the epic {epic_subject}."
|
||||
),
|
||||
"delete": (
|
||||
"{user} removed a related user story {userstory_subject} from the epic {epic_subject}."
|
||||
),
|
||||
},
|
||||
"userstory": {
|
||||
"create": "{user} created user story {subject}.",
|
||||
"set_assigned_to": "{user} assigned user story {subject} to {new}.",
|
||||
"unset_assigned_to": "{user} unassigned user story {subject}.",
|
||||
"changed_assigned_to": "{user} reassigned user story {subject} from {old} to {new}.",
|
||||
"points": "{user} changed estimation of user story {subject}.",
|
||||
"blocked": "{user} blocked user story {subject}.",
|
||||
"unblocked": "{user} unblocked user story {subject}.",
|
||||
"set_milestone": "{user} added user story {subject} to sprint {new}.",
|
||||
"unset_milestone": "{user} removed user story {subject} from sprint {old}.",
|
||||
"changed_milestone": "{user} changed sprint of user story {subject} from {old} to {new}.",
|
||||
"changed_status": "{user} changed status of user story {subject} from {old} to {new}.",
|
||||
"closed": "{user} closed user story {subject}.",
|
||||
"reopened": "{user} reopened user story {subject}.",
|
||||
"renamed": "{user} renamed user story from {old} to **{new}**.",
|
||||
"description_diff": "{user} updated description of user story {subject}.",
|
||||
"commented": "{user} commented on user story {subject}.",
|
||||
"delete": "{user} deleted user story {subject}.",
|
||||
"due_date": "{user} changed due date of user story {subject} from {old} to {new}.",
|
||||
"set_due_date": "{user} set due date of user story {subject} to {new}.",
|
||||
},
|
||||
"milestone": {
|
||||
"create": "{user} created sprint {subject}.",
|
||||
"renamed": "{user} renamed sprint from {old} to **{new}**.",
|
||||
"estimated_start": "{user} changed estimated start of sprint {subject}"
|
||||
" from {old} to {new}.",
|
||||
"estimated_finish": "{user} changed estimated finish of sprint {subject}"
|
||||
" from {old} to {new}.",
|
||||
"set_estimated_start": "{user} changed estimated start of sprint {subject} to {new}.",
|
||||
"set_estimated_finish": "{user} set estimated finish of sprint {subject} to {new}.",
|
||||
"delete": "{user} deleted sprint {subject}.",
|
||||
},
|
||||
"task": {
|
||||
"create": "{user} created task {subject}.",
|
||||
"set_assigned_to": "{user} assigned task {subject} to {new}.",
|
||||
"unset_assigned_to": "{user} unassigned task {subject}.",
|
||||
"changed_assigned_to": "{user} reassigned task {subject} from {old} to {new}.",
|
||||
"blocked": "{user} blocked task {subject}.",
|
||||
"unblocked": "{user} unblocked task {subject}.",
|
||||
"changed_status": "{user} changed status of task {subject} from {old} to {new}.",
|
||||
"renamed": "{user} renamed task {old} to **{new}**.",
|
||||
"description_diff": "{user} updated description of task {subject}.",
|
||||
"set_milestone": "{user} added task {subject} to sprint {new}.",
|
||||
"commented": "{user} commented on task {subject}.",
|
||||
"delete": "{user} deleted task {subject}.",
|
||||
"changed_us": "{user} moved task {subject} from user story {old} to {new}.",
|
||||
"due_date": "{user} changed due date of task {subject} from {old} to {new}.",
|
||||
"set_due_date": "{user} set due date of task {subject} to {new}.",
|
||||
},
|
||||
"issue": {
|
||||
"create": "{user} created issue {subject}.",
|
||||
"set_assigned_to": "{user} assigned issue {subject} to {new}.",
|
||||
"unset_assigned_to": "{user} unassigned issue {subject}.",
|
||||
"changed_assigned_to": "{user} reassigned issue {subject} from {old} to {new}.",
|
||||
"set_milestone": "{user} added issue {subject} to sprint {new}.",
|
||||
"unset_milestone": "{user} detached issue {subject} from sprint {old}.",
|
||||
"changed_priority": "{user} changed priority of issue {subject} from {old} to {new}.",
|
||||
"changed_severity": "{user} changed severity of issue {subject} from {old} to {new}.",
|
||||
"changed_status": "{user} changed status of issue {subject} from {old} to {new}.",
|
||||
"changed_type": "{user} changed type of issue {subject} from {old} to {new}.",
|
||||
"renamed": "{user} renamed issue {old} to **{new}**.",
|
||||
"description_diff": "{user} updated description of issue {subject}.",
|
||||
"commented": "{user} commented on issue {subject}.",
|
||||
"delete": "{user} deleted issue {subject}.",
|
||||
"due_date": "{user} changed due date of issue {subject} from {old} to {new}.",
|
||||
"set_due_date": "{user} set due date of issue {subject} to {new}.",
|
||||
"blocked": "{user} blocked issue {subject}.",
|
||||
"unblocked": "{user} unblocked issue {subject}.",
|
||||
},
|
||||
"webhook_test": {
|
||||
"test": "{user} triggered a test of the Taiga integration.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_old_and_new_values(change_type: str, message: WildValue) -> tuple[str, str]:
|
||||
diff = message["change"]["diff"][change_type]
|
||||
old = diff.get("from").tame(check_none_or(check_string))
|
||||
new = diff.get("to").tame(check_none_or(check_string))
|
||||
return old or "", new or ""
|
||||
|
||||
|
||||
def parse_comment(
|
||||
message: WildValue,
|
||||
) -> EventType:
|
||||
return {
|
||||
"event": "commented",
|
||||
"type": message["type"].tame(check_string),
|
||||
"values": {
|
||||
"user": get_display_user(message),
|
||||
"subject": get_subject(message),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_create_or_delete(
|
||||
message: WildValue,
|
||||
) -> EventType:
|
||||
event_type = message["type"].tame(check_string)
|
||||
|
||||
values: dict[str, str | bool | None] = {"user": get_display_user(message)}
|
||||
if event_type == "relateduserstory":
|
||||
values["epic_subject"] = get_subject(message, "epic")
|
||||
values["userstory_subject"] = get_subject(message, "user_story")
|
||||
else:
|
||||
values["subject"] = get_subject(message)
|
||||
|
||||
return EventType(type=event_type, event=message["action"].tame(check_string), values=values)
|
||||
|
||||
|
||||
def parse_change_event(change_type: str, message: WildValue) -> EventType | None:
|
||||
diff_data = message["change"]["diff"]
|
||||
values: dict[str, str | bool | None] = {
|
||||
"user": get_display_user(message),
|
||||
"subject": get_subject(message),
|
||||
}
|
||||
event_type: str | None
|
||||
|
||||
match change_type:
|
||||
case "description_diff" | "points":
|
||||
event_type = change_type
|
||||
|
||||
case "milestone" | "assigned_to" | "estimated_finish" | "estimated_start" | "due_date":
|
||||
old, new = get_old_and_new_values(change_type, message)
|
||||
if not old:
|
||||
event_type, values["new"] = f"set_{change_type}", new
|
||||
elif not new and change_type in ("milestone", "assigned_to"):
|
||||
event_type, values["old"] = f"unset_{change_type}", old
|
||||
elif old != new:
|
||||
event_type = (
|
||||
change_type
|
||||
if "date" in change_type or "estimated" in change_type
|
||||
else f"changed_{change_type}"
|
||||
)
|
||||
values.update(old=old, new=new)
|
||||
else:
|
||||
return None
|
||||
|
||||
case "is_blocked" | "is_closed":
|
||||
is_to = diff_data[change_type]["to"].tame(check_bool)
|
||||
mapping = {"is_blocked": ("blocked", "unblocked"), "is_closed": ("closed", "reopened")}
|
||||
event_type = mapping[change_type][0] if is_to else mapping[change_type][1]
|
||||
|
||||
case "user_story":
|
||||
event_type = "changed_us"
|
||||
values["old"], values["new"] = get_old_and_new_values(change_type, message)
|
||||
|
||||
case "subject" | "name":
|
||||
event_type = "renamed"
|
||||
values["old"], values["new"] = get_old_and_new_values(change_type, message)
|
||||
|
||||
case "priority" | "severity" | "type" | "status":
|
||||
event_type = f"changed_{change_type}"
|
||||
values["old"], values["new"] = get_old_and_new_values(change_type, message)
|
||||
|
||||
case _:
|
||||
return None
|
||||
|
||||
return EventType(type=message["type"].tame(check_string), event=event_type, values=values)
|
||||
|
||||
|
||||
def parse_webhook_test(
|
||||
message: WildValue,
|
||||
) -> EventType:
|
||||
return {
|
||||
"type": "webhook_test",
|
||||
"event": "test",
|
||||
"values": {
|
||||
"user": get_display_user(message),
|
||||
"end_type": "test",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_message(
|
||||
message: WildValue,
|
||||
) -> list[EventType]:
|
||||
events: list[EventType] = []
|
||||
action = message["action"].tame(check_string)
|
||||
|
||||
match action:
|
||||
case "create" | "delete":
|
||||
events.append(parse_create_or_delete(message))
|
||||
case "change":
|
||||
if message["change"]["diff"]:
|
||||
for value in message["change"]["diff"].keys(): # noqa: SIM118
|
||||
parsed_event = parse_change_event(value, message)
|
||||
if parsed_event:
|
||||
events.append(parsed_event)
|
||||
if message["change"]["comment"].tame(check_string):
|
||||
events.append(parse_comment(message))
|
||||
case "test":
|
||||
events.append(parse_webhook_test(message))
|
||||
case _: # nocoverage
|
||||
pass
|
||||
|
||||
return events
|
||||
|
||||
|
||||
def generate_content(data: EventType) -> str:
|
||||
template = TEMPLATES[data["type"]][data["event"]]
|
||||
content = template.format(**data["values"])
|
||||
return content
|
||||
|
||||
|
||||
def get_display_user(message: WildValue) -> str:
|
||||
owner_name = message["by"]["full_name"].tame(check_string)
|
||||
owner_link = message["by"]["permalink"].tame(check_string)
|
||||
return f"[{owner_name}]({owner_link})"
|
||||
|
||||
|
||||
def format_subject(subject_text: str, permalink: str | None) -> str:
|
||||
return f"[{subject_text}]({permalink})" if permalink else f"**{subject_text}**"
|
||||
|
||||
|
||||
def get_subject(message: WildValue, data_field: str | None = None) -> str:
|
||||
data = message["data"][data_field] if data_field is not None else message["data"]
|
||||
text = data.get("subject").tame(check_none_or(check_string)) or data["name"].tame(check_string)
|
||||
permalink = data.get("permalink").tame(check_none_or(check_string))
|
||||
return format_subject(text, permalink)
|
||||
Reference in New Issue
Block a user