mirror of
https://github.com/zulip/zulip.git
synced 2025-11-04 14:03:30 +00:00
- Made the branch-filtering checks uniform across all the integrations, by adding a helper function to git.py, and re-using it. - Instead of checking if the name of the branch that generated the event is a substring of the "branches" parameter, we now check if there's an exact match. For example, if there are two branches named "main" and "release/v1.0-main", and the user wants to track pushes to only the "release/v1.0-main" branch, they wouldn't have been able to previously, it will always track pushes to both branches. There was no way to filter out the smaller named branch when there were overlaps.
560 lines
20 KiB
Python
560 lines
20 KiB
Python
# Webhooks for external integrations.
|
|
import re
|
|
import string
|
|
from typing import Protocol
|
|
|
|
from django.http import HttpRequest, HttpResponse
|
|
|
|
from zerver.decorator import log_unsupported_webhook_event, webhook_view
|
|
from zerver.lib.exceptions import UnsupportedWebhookEventTypeError
|
|
from zerver.lib.partial import partial
|
|
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_int, check_string
|
|
from zerver.lib.webhooks.common import (
|
|
OptionalUserSpecifiedTopicStr,
|
|
check_send_webhook_message,
|
|
validate_extract_webhook_http_header,
|
|
)
|
|
from zerver.lib.webhooks.git import (
|
|
TOPIC_WITH_BRANCH_TEMPLATE,
|
|
TOPIC_WITH_PR_OR_ISSUE_INFO_TEMPLATE,
|
|
get_commits_comment_action_message,
|
|
get_force_push_commits_event_message,
|
|
get_issue_event_message,
|
|
get_pull_request_event_message,
|
|
get_push_commits_event_message,
|
|
get_push_tag_event_message,
|
|
get_remove_branch_event_message,
|
|
get_short_sha,
|
|
is_branch_name_notifiable,
|
|
)
|
|
from zerver.models import UserProfile
|
|
|
|
BITBUCKET_TOPIC_TEMPLATE = "{repository_name}"
|
|
|
|
BITBUCKET_FORK_BODY = "{actor} forked the repository into [{fork_name}]({fork_url})."
|
|
BITBUCKET_COMMIT_STATUS_CHANGED_BODY = (
|
|
"[System {key}]({system_url}) changed status of {commit_info} to {status}."
|
|
)
|
|
BITBUCKET_REPO_UPDATED_CHANGED = (
|
|
"{actor} changed the {change} of the **{repo_name}** repo from **{old}** to **{new}**"
|
|
)
|
|
BITBUCKET_REPO_UPDATED_ADDED = (
|
|
"{actor} changed the {change} of the **{repo_name}** repo to **{new}**"
|
|
)
|
|
|
|
PULL_REQUEST_SUPPORTED_ACTIONS = [
|
|
"approved",
|
|
"unapproved",
|
|
"created",
|
|
"updated",
|
|
"rejected",
|
|
"fulfilled",
|
|
"comment_created",
|
|
"comment_updated",
|
|
"comment_deleted",
|
|
]
|
|
|
|
ALL_EVENT_TYPES = [
|
|
"change_commit_status",
|
|
"pull_request_comment_created",
|
|
"pull_request_updated",
|
|
"pull_request_unapproved",
|
|
"push",
|
|
"pull_request_approved",
|
|
"pull_request_fulfilled",
|
|
"issue_created",
|
|
"issue_commented",
|
|
"fork",
|
|
"pull_request_comment_updated",
|
|
"pull_request_created",
|
|
"pull_request_rejected",
|
|
"repo:updated",
|
|
"issue_updated",
|
|
"commit_comment",
|
|
"pull_request_comment_deleted",
|
|
]
|
|
|
|
|
|
@webhook_view("Bitbucket2", all_event_types=ALL_EVENT_TYPES)
|
|
@typed_endpoint
|
|
def api_bitbucket2_webhook(
|
|
request: HttpRequest,
|
|
user_profile: UserProfile,
|
|
*,
|
|
payload: JsonBodyPayload[WildValue],
|
|
branches: str | None = None,
|
|
user_specified_topic: OptionalUserSpecifiedTopicStr = None,
|
|
) -> HttpResponse:
|
|
type = get_type(request, payload)
|
|
if type == "push":
|
|
# ignore push events with no changes
|
|
if not payload["push"]["changes"]:
|
|
return json_success(request)
|
|
branch = get_branch_name_for_push_event(payload)
|
|
if branch and not is_branch_name_notifiable(branch, branches):
|
|
return json_success(request)
|
|
|
|
topic_names = get_push_topics(payload)
|
|
bodies = get_push_bodies(request, payload)
|
|
|
|
for b, t in zip(bodies, topic_names, strict=False):
|
|
check_send_webhook_message(
|
|
request, user_profile, t, b, type, unquote_url_parameters=True
|
|
)
|
|
else:
|
|
topic_name = get_topic_based_on_type(payload, type)
|
|
body_function = get_body_based_on_type(type)
|
|
body = body_function(
|
|
request,
|
|
payload,
|
|
include_title=user_specified_topic is not None,
|
|
)
|
|
|
|
check_send_webhook_message(
|
|
request, user_profile, topic_name, body, type, unquote_url_parameters=True
|
|
)
|
|
|
|
return json_success(request)
|
|
|
|
|
|
def get_topic_for_branch_specified_events(
|
|
payload: WildValue, branch_name: str | None = None
|
|
) -> str:
|
|
return TOPIC_WITH_BRANCH_TEMPLATE.format(
|
|
repo=get_repository_name(payload["repository"]),
|
|
branch=get_branch_name_for_push_event(payload) if branch_name is None else branch_name,
|
|
)
|
|
|
|
|
|
def get_push_topics(payload: WildValue) -> list[str]:
|
|
topics_list = []
|
|
for change in payload["push"]["changes"]:
|
|
potential_tag = (change["new"] or change["old"])["type"].tame(check_string)
|
|
if potential_tag == "tag":
|
|
topics_list.append(get_topic(payload))
|
|
else:
|
|
if change.get("new"):
|
|
branch_name = change["new"]["name"].tame(check_string)
|
|
else:
|
|
branch_name = change["old"]["name"].tame(check_string)
|
|
topics_list.append(get_topic_for_branch_specified_events(payload, branch_name))
|
|
return topics_list
|
|
|
|
|
|
def get_topic(payload: WildValue) -> str:
|
|
return BITBUCKET_TOPIC_TEMPLATE.format(
|
|
repository_name=get_repository_name(payload["repository"])
|
|
)
|
|
|
|
|
|
def get_topic_based_on_type(payload: WildValue, type: str) -> str:
|
|
if type.startswith("pull_request"):
|
|
return TOPIC_WITH_PR_OR_ISSUE_INFO_TEMPLATE.format(
|
|
repo=get_repository_name(payload["repository"]),
|
|
type="PR",
|
|
id=payload["pullrequest"]["id"].tame(check_int),
|
|
title=payload["pullrequest"]["title"].tame(check_string),
|
|
)
|
|
if type.startswith("issue"):
|
|
return TOPIC_WITH_PR_OR_ISSUE_INFO_TEMPLATE.format(
|
|
repo=get_repository_name(payload["repository"]),
|
|
type="issue",
|
|
id=payload["issue"]["id"].tame(check_int),
|
|
title=payload["issue"]["title"].tame(check_string),
|
|
)
|
|
assert type != "push"
|
|
return get_topic(payload)
|
|
|
|
|
|
def get_type(request: HttpRequest, payload: WildValue) -> str:
|
|
if "push" in payload:
|
|
return "push"
|
|
elif "fork" in payload:
|
|
return "fork"
|
|
elif "comment" in payload and "commit" in payload:
|
|
return "commit_comment"
|
|
elif "commit_status" in payload:
|
|
return "change_commit_status"
|
|
elif "issue" in payload:
|
|
if "changes" in payload:
|
|
return "issue_updated"
|
|
if "comment" in payload:
|
|
return "issue_commented"
|
|
return "issue_created"
|
|
elif "pullrequest" in payload:
|
|
pull_request_template = "pull_request_{}"
|
|
# Note that we only need the HTTP header to determine pullrequest events.
|
|
# We rely on the payload itself to determine the other ones.
|
|
event_key = validate_extract_webhook_http_header(request, "X-Event-Key", "BitBucket")
|
|
action = re.match(r"pullrequest:(?P<action>.*)$", event_key)
|
|
if action:
|
|
action_group = action.group("action")
|
|
if action_group in PULL_REQUEST_SUPPORTED_ACTIONS:
|
|
return pull_request_template.format(action_group)
|
|
else:
|
|
event_key = validate_extract_webhook_http_header(request, "X-Event-Key", "BitBucket")
|
|
if event_key == "repo:updated":
|
|
return event_key
|
|
|
|
raise UnsupportedWebhookEventTypeError(event_key)
|
|
|
|
|
|
class BodyGetter(Protocol):
|
|
def __call__(self, request: HttpRequest, payload: WildValue, include_title: bool) -> str: ...
|
|
|
|
|
|
def get_body_based_on_type(
|
|
type: str,
|
|
) -> BodyGetter:
|
|
return GET_SINGLE_MESSAGE_BODY_DEPENDING_ON_TYPE_MAPPER[type]
|
|
|
|
|
|
def get_push_bodies(request: HttpRequest, payload: WildValue) -> list[str]:
|
|
messages_list = []
|
|
for change in payload["push"]["changes"]:
|
|
potential_tag = (change["new"] or change["old"])["type"].tame(check_string)
|
|
if potential_tag == "tag":
|
|
messages_list.append(get_push_tag_body(request, payload, change))
|
|
# if change['new'] is None, that means a branch was deleted
|
|
elif change["new"].value is None:
|
|
messages_list.append(get_remove_branch_push_body(request, payload, change))
|
|
elif change["forced"].tame(check_bool):
|
|
messages_list.append(get_force_push_body(request, payload, change))
|
|
else:
|
|
messages_list.append(get_normal_push_body(request, payload, change))
|
|
return messages_list
|
|
|
|
|
|
def get_remove_branch_push_body(request: HttpRequest, payload: WildValue, change: WildValue) -> str:
|
|
return get_remove_branch_event_message(
|
|
get_actor_info(request, payload),
|
|
change["old"]["name"].tame(check_string),
|
|
)
|
|
|
|
|
|
def get_force_push_body(request: HttpRequest, payload: WildValue, change: WildValue) -> str:
|
|
return get_force_push_commits_event_message(
|
|
get_actor_info(request, payload),
|
|
change["links"]["html"]["href"].tame(check_string),
|
|
change["new"]["name"].tame(check_string),
|
|
change["new"]["target"]["hash"].tame(check_string),
|
|
)
|
|
|
|
|
|
def get_commit_author_name(request: HttpRequest, commit: WildValue) -> str:
|
|
if "user" in commit["author"]:
|
|
return get_user_info(request, commit["author"]["user"])
|
|
return commit["author"]["raw"].tame(check_string).split()[0]
|
|
|
|
|
|
def get_normal_push_body(request: HttpRequest, payload: WildValue, change: WildValue) -> str:
|
|
commits_data = [
|
|
{
|
|
"name": get_commit_author_name(request, commit),
|
|
"sha": commit["hash"].tame(check_string),
|
|
"url": commit["links"]["html"]["href"].tame(check_string),
|
|
"message": commit["message"].tame(check_string),
|
|
}
|
|
for commit in change["commits"]
|
|
]
|
|
|
|
return get_push_commits_event_message(
|
|
get_actor_info(request, payload),
|
|
change["links"]["html"]["href"].tame(check_string),
|
|
change["new"]["name"].tame(check_string),
|
|
commits_data,
|
|
is_truncated=change["truncated"].tame(check_bool),
|
|
)
|
|
|
|
|
|
def get_fork_body(request: HttpRequest, payload: WildValue, include_title: bool) -> str:
|
|
return BITBUCKET_FORK_BODY.format(
|
|
actor=get_user_info(request, payload["actor"]),
|
|
fork_name=get_repository_full_name(payload["fork"]),
|
|
fork_url=get_repository_url(payload["fork"]),
|
|
)
|
|
|
|
|
|
def get_commit_comment_body(request: HttpRequest, payload: WildValue, include_title: bool) -> str:
|
|
comment = payload["comment"]
|
|
action = "[commented]({})".format(comment["links"]["html"]["href"].tame(check_string))
|
|
return get_commits_comment_action_message(
|
|
get_actor_info(request, payload),
|
|
action,
|
|
comment["commit"]["links"]["html"]["href"].tame(check_string),
|
|
comment["commit"]["hash"].tame(check_string),
|
|
comment["content"]["raw"].tame(check_string),
|
|
)
|
|
|
|
|
|
def get_commit_status_changed_body(
|
|
request: HttpRequest, payload: WildValue, include_title: bool
|
|
) -> str:
|
|
commit_api_url = payload["commit_status"]["links"]["commit"]["href"].tame(check_string)
|
|
commit_id = commit_api_url.split("/")[-1]
|
|
|
|
commit_info = "[{short_commit_id}]({repo_url}/commits/{commit_id})".format(
|
|
repo_url=get_repository_url(payload["repository"]),
|
|
short_commit_id=get_short_sha(commit_id),
|
|
commit_id=commit_id,
|
|
)
|
|
|
|
return BITBUCKET_COMMIT_STATUS_CHANGED_BODY.format(
|
|
key=payload["commit_status"]["key"].tame(check_string),
|
|
system_url=payload["commit_status"]["url"].tame(check_string),
|
|
commit_info=commit_info,
|
|
status=payload["commit_status"]["state"].tame(check_string),
|
|
)
|
|
|
|
|
|
def get_issue_commented_body(request: HttpRequest, payload: WildValue, include_title: bool) -> str:
|
|
action = "[commented]({}) on".format(
|
|
payload["comment"]["links"]["html"]["href"].tame(check_string)
|
|
)
|
|
return get_issue_action_body(action, request, payload, include_title)
|
|
|
|
|
|
def get_issue_action_body(
|
|
action: str, request: HttpRequest, payload: WildValue, include_title: bool
|
|
) -> str:
|
|
issue = payload["issue"]
|
|
assignee = None
|
|
message = None
|
|
if action == "created":
|
|
if issue["assignee"]:
|
|
assignee = get_user_info(request, issue["assignee"])
|
|
message = issue["content"]["raw"].tame(check_string)
|
|
|
|
return get_issue_event_message(
|
|
user_name=get_actor_info(request, payload),
|
|
action=action,
|
|
url=issue["links"]["html"]["href"].tame(check_string),
|
|
number=issue["id"].tame(check_int),
|
|
message=message,
|
|
assignee=assignee,
|
|
title=issue["title"].tame(check_string) if include_title else None,
|
|
)
|
|
|
|
|
|
def get_pull_request_action_body(
|
|
action: str, request: HttpRequest, payload: WildValue, include_title: bool
|
|
) -> str:
|
|
pull_request = payload["pullrequest"]
|
|
target_branch = None
|
|
base_branch = None
|
|
if action == "merged":
|
|
target_branch = pull_request["source"]["branch"]["name"].tame(check_string)
|
|
base_branch = pull_request["destination"]["branch"]["name"].tame(check_string)
|
|
|
|
return get_pull_request_event_message(
|
|
user_name=get_actor_info(request, payload),
|
|
action=action,
|
|
url=get_pull_request_url(pull_request),
|
|
number=pull_request["id"].tame(check_int),
|
|
target_branch=target_branch,
|
|
base_branch=base_branch,
|
|
title=pull_request["title"].tame(check_string) if include_title else None,
|
|
)
|
|
|
|
|
|
def get_pull_request_created_or_updated_body(
|
|
action: str, request: HttpRequest, payload: WildValue, include_title: bool
|
|
) -> str:
|
|
pull_request = payload["pullrequest"]
|
|
assignee = None
|
|
if pull_request["reviewers"]:
|
|
assignee = get_user_info(request, pull_request["reviewers"][0])
|
|
|
|
return get_pull_request_event_message(
|
|
user_name=get_actor_info(request, payload),
|
|
action=action,
|
|
url=get_pull_request_url(pull_request),
|
|
number=pull_request["id"].tame(check_int),
|
|
target_branch=(
|
|
pull_request["source"]["branch"]["name"].tame(check_string)
|
|
if action == "created"
|
|
else None
|
|
),
|
|
base_branch=(
|
|
pull_request["destination"]["branch"]["name"].tame(check_string)
|
|
if action == "created"
|
|
else None
|
|
),
|
|
message=pull_request["description"].tame(check_string),
|
|
assignee=assignee,
|
|
title=pull_request["title"].tame(check_string) if include_title else None,
|
|
)
|
|
|
|
|
|
def get_pull_request_comment_created_action_body(
|
|
request: HttpRequest,
|
|
payload: WildValue,
|
|
include_title: bool,
|
|
) -> str:
|
|
action = "[commented]({})".format(
|
|
payload["comment"]["links"]["html"]["href"].tame(check_string)
|
|
)
|
|
return get_pull_request_comment_action_body(request, payload, action, include_title)
|
|
|
|
|
|
def get_pull_request_deleted_or_updated_comment_action_body(
|
|
action: str,
|
|
request: HttpRequest,
|
|
payload: WildValue,
|
|
include_title: bool,
|
|
) -> str:
|
|
action = "{} a [comment]({})".format(
|
|
action, payload["comment"]["links"]["html"]["href"].tame(check_string)
|
|
)
|
|
return get_pull_request_comment_action_body(request, payload, action, include_title)
|
|
|
|
|
|
def get_pull_request_comment_action_body(
|
|
request: HttpRequest,
|
|
payload: WildValue,
|
|
action: str,
|
|
include_title: bool,
|
|
) -> str:
|
|
action += " on"
|
|
return get_pull_request_event_message(
|
|
user_name=get_actor_info(request, payload),
|
|
action=action,
|
|
url=payload["pullrequest"]["links"]["html"]["href"].tame(check_string),
|
|
number=payload["pullrequest"]["id"].tame(check_int),
|
|
message=payload["comment"]["content"]["raw"].tame(check_string),
|
|
title=payload["pullrequest"]["title"].tame(check_string) if include_title else None,
|
|
)
|
|
|
|
|
|
def get_push_tag_body(request: HttpRequest, payload: WildValue, change: WildValue) -> str:
|
|
if change.get("new"):
|
|
tag = change["new"]
|
|
action = "pushed"
|
|
elif change.get("old"):
|
|
tag = change["old"]
|
|
action = "removed"
|
|
|
|
return get_push_tag_event_message(
|
|
get_actor_info(request, payload),
|
|
tag["name"].tame(check_string),
|
|
tag_url=tag["links"]["html"]["href"].tame(check_string),
|
|
action=action,
|
|
)
|
|
|
|
|
|
def append_punctuation(title: str, message: str) -> str:
|
|
if title[-1] not in string.punctuation:
|
|
message = f"{message}."
|
|
|
|
return message
|
|
|
|
|
|
def get_repo_updated_body(request: HttpRequest, payload: WildValue, include_title: bool) -> str:
|
|
changes = ["website", "name", "links", "language", "full_name", "description"]
|
|
body = ""
|
|
repo_name = payload["repository"]["name"].tame(check_string)
|
|
actor = get_actor_info(request, payload)
|
|
|
|
for change in changes:
|
|
new = payload["changes"][change]["new"]
|
|
old = payload["changes"][change]["old"]
|
|
if change == "full_name":
|
|
change = "full name"
|
|
if new and old:
|
|
message = BITBUCKET_REPO_UPDATED_CHANGED.format(
|
|
actor=actor,
|
|
change=change,
|
|
repo_name=repo_name,
|
|
old=str(old.value),
|
|
new=str(new.value),
|
|
)
|
|
message = append_punctuation(str(new.value), message) + "\n"
|
|
body += message
|
|
elif new and not old:
|
|
message = BITBUCKET_REPO_UPDATED_ADDED.format(
|
|
actor=actor,
|
|
change=change,
|
|
repo_name=repo_name,
|
|
new=str(new.value),
|
|
)
|
|
message = append_punctuation(str(new.value), message) + "\n"
|
|
body += message
|
|
|
|
return body
|
|
|
|
|
|
def get_pull_request_url(pullrequest_payload: WildValue) -> str:
|
|
return pullrequest_payload["links"]["html"]["href"].tame(check_string)
|
|
|
|
|
|
def get_repository_url(repository_payload: WildValue) -> str:
|
|
return repository_payload["links"]["html"]["href"].tame(check_string)
|
|
|
|
|
|
def get_repository_name(repository_payload: WildValue) -> str:
|
|
return repository_payload["name"].tame(check_string)
|
|
|
|
|
|
def get_repository_full_name(repository_payload: WildValue) -> str:
|
|
return repository_payload["full_name"].tame(check_string)
|
|
|
|
|
|
def get_user_info(request: HttpRequest, dct: WildValue) -> str:
|
|
# See https://developer.atlassian.com/cloud/bitbucket/bitbucket-api-changes-gdpr/
|
|
# Since GDPR, we don't get username; instead, we either get display_name
|
|
# or nickname.
|
|
if "display_name" in dct:
|
|
return dct["display_name"].tame(check_string)
|
|
|
|
if "nickname" in dct:
|
|
return dct["nickname"].tame(check_string)
|
|
|
|
# We call this an unsupported_event, even though we
|
|
# are technically still sending a message.
|
|
log_unsupported_webhook_event(
|
|
request=request,
|
|
summary="Could not find display_name/nickname field",
|
|
)
|
|
|
|
return "Unknown user"
|
|
|
|
|
|
def get_actor_info(request: HttpRequest, payload: WildValue) -> str:
|
|
actor = payload["actor"]
|
|
return get_user_info(request, actor)
|
|
|
|
|
|
def get_branch_name_for_push_event(payload: WildValue) -> str | None:
|
|
change = payload["push"]["changes"][-1]
|
|
potential_tag = (change["new"] or change["old"])["type"].tame(check_string)
|
|
if potential_tag == "tag":
|
|
return None
|
|
else:
|
|
return (change["new"] or change["old"])["name"].tame(check_string)
|
|
|
|
|
|
GET_SINGLE_MESSAGE_BODY_DEPENDING_ON_TYPE_MAPPER: dict[str, BodyGetter] = {
|
|
"fork": get_fork_body,
|
|
"commit_comment": get_commit_comment_body,
|
|
"change_commit_status": get_commit_status_changed_body,
|
|
"issue_updated": partial(get_issue_action_body, "updated"),
|
|
"issue_created": partial(get_issue_action_body, "created"),
|
|
"issue_commented": get_issue_commented_body,
|
|
"pull_request_created": partial(get_pull_request_created_or_updated_body, "created"),
|
|
"pull_request_updated": partial(get_pull_request_created_or_updated_body, "updated"),
|
|
"pull_request_approved": partial(get_pull_request_action_body, "approved"),
|
|
"pull_request_unapproved": partial(get_pull_request_action_body, "unapproved"),
|
|
"pull_request_fulfilled": partial(get_pull_request_action_body, "merged"),
|
|
"pull_request_rejected": partial(get_pull_request_action_body, "rejected"),
|
|
"pull_request_comment_created": get_pull_request_comment_created_action_body,
|
|
"pull_request_comment_updated": partial(
|
|
get_pull_request_deleted_or_updated_comment_action_body, "updated"
|
|
),
|
|
"pull_request_comment_deleted": partial(
|
|
get_pull_request_deleted_or_updated_comment_action_body, "deleted"
|
|
),
|
|
"repo:updated": get_repo_updated_body,
|
|
}
|