Repository: zulip/zulip

Issue:
Bitbucket Server: Complete Support and Add Enhancements
**Contents:**
This pull request consists of 2 commits.
1. Complete the Bitbucket server (bitbucket 3) incoming webhook integration by extending support for all pull request events. 
2. Enhance and refactor the code. From the commit message:
   "In terms of minor enhancements, this commit will now link each
    actor's name with the url to their profile thus giving the messages
    a much more complete feel. Also, for when comments on a commit are
    deleted, the quote will now use stikethrough text.
    
    In terms of refactoring, this commit removes the unnecessary line
    splitting in this integration's tests (mainly observed with the
    expected_message variables) thus making it easier to read."

**Testing Plan:** <!-- How have you tested? -->
A plethora of new automated tests have been added and additional testing has been done manually. Since this is quite a bit of code to review all at once, I've made a small script that could make the process of viewing the output a bit more enjoyable by generating all of the fixture messages at one go so that they can easily be viewed from the development environment - i.e. after running `./tools/run-dev.py` (see attachment: [visual-test.zip](https://github.com/zulip/zulip/files/3009417/visual-test.zip)) - some "macros" (just variables) need to be manually set and then this script can be run after placing in the top level zulip directory.


Discussion:

Resolving patch:
diff --git a/zerver/webhooks/bitbucket3/view.py b/zerver/webhooks/bitbucket3/view.py
--- a/zerver/webhooks/bitbucket3/view.py
+++ b/zerver/webhooks/bitbucket3/view.py
@@ -10,26 +10,54 @@
 from zerver.lib.response import json_success
 from zerver.lib.webhooks.git import TOPIC_WITH_BRANCH_TEMPLATE, \
     get_push_tag_event_message, get_remove_branch_event_message, \
-    get_create_branch_event_message, get_commits_comment_action_message
+    get_create_branch_event_message, get_commits_comment_action_message, \
+    TOPIC_WITH_PR_OR_ISSUE_INFO_TEMPLATE, get_pull_request_event_message, \
+    CONTENT_MESSAGE_TEMPLATE
 from zerver.lib.webhooks.common import check_send_webhook_message, \
     UnexpectedWebhookEventType
 from zerver.webhooks.bitbucket2.view import BITBUCKET_TOPIC_TEMPLATE, \
     BITBUCKET_FORK_BODY, BITBUCKET_REPO_UPDATED_CHANGED
 
-BRANCH_UPDATED_MESSAGE_TEMPLATE = "{user_name} pushed to branch {branch_name}. Head is now {head}"
+BRANCH_UPDATED_MESSAGE_TEMPLATE = "{user_name} pushed to branch {branch_name}. Head is now {head}."
+PULL_REQUEST_MARKED_AS_NEEDS_WORK_TEMPLATE = """{user_name} marked [PR #{number}]({url}) \
+as \"needs work\""""
+PULL_REQUEST_MARKED_AS_NEEDS_WORK_TEMPLATE_WITH_TITLE = """{user_name} marked \
+[PR #{number} {title}]({url}) as \"needs work\""""
+PULL_REQUEST_REASSIGNED_TEMPLATE = """{user_name} reassigned [PR #{number}]({url}) \
+to {assignees}"""
+PULL_REQUEST_REASSIGNED_TEMPLATE_WITH_TITLE = """{user_name} reassigned [PR #{number} \
+{title}]({url}) to {assignees}"""
+PULL_REQUEST_REASSIGNED_TO_NONE_TEMPLATE = """{user_name} removed all reviewers from [PR \
+#{number}]({url})"""
+PULL_REQUEST_REASSIGNED_TO_NONE_TEMPLATE_WITH_TITLE = """{user_name} removed all reviewers \
+from [PR #{number} {title}]({url})"""
+PULL_REQUEST_OPENED_OR_MODIFIED_TEMPLATE_WITH_REVIEWERS = """{user_name} {action} [PR #{number}]\
+({url})\nfrom `{source}` to `{destination}` (assigned to {assignees} for review)"""
+PULL_REQUEST_OPENED_OR_MODIFIED_TEMPLATE_WITH_REVIEWERS_WITH_TITLE = """{user_name} {action} \
+[PR #{number} {title}]({url})\nfrom `{source}` to `{destination}` (assigned to {assignees} for \
+review)"""
+
+def get_user_name(payload: Dict[str, Any]) -> str:
+    user_name = "[{name}]({url})".format(name=payload["actor"]["name"],
+                                         url=payload["actor"]["links"]["self"][0]["href"])
+    return user_name
 
 def repo_comment_handler(payload: Dict[str, Any], action: str) -> List[Dict[str, str]]:
     repo_name = payload["repository"]["name"]
-    user_name = payload["actor"]["name"]
     subject = BITBUCKET_TOPIC_TEMPLATE.format(repository_name=repo_name)
     sha = payload["commit"]
     commit_url = payload["repository"]["links"]["self"][0]["href"][:-6]  # remove the "browse" at the end
     commit_url += "commits/%s" % (sha,)
-    body = get_commits_comment_action_message(user_name=user_name,
-                                              action=action,
-                                              commit_url=commit_url,
-                                              sha=sha,
-                                              message=payload["comment"]["text"])
+    message = payload["comment"]["text"]
+    if action == "deleted their comment":
+        message = "~~{message}~~".format(message=message)
+    body = get_commits_comment_action_message(
+        user_name=get_user_name(payload),
+        action=action,
+        commit_url=commit_url,
+        sha=sha,
+        message=message
+    )
     return [{"subject": subject, "body": body}]
 
 def repo_forked_handler(payload: Dict[str, Any]) -> List[Dict[str, str]]:
@@ -37,7 +65,7 @@ def repo_forked_handler(payload: Dict[str, Any]) -> List[Dict[str, str]]:
     subject = BITBUCKET_TOPIC_TEMPLATE.format(repository_name=repo_name)
     body = BITBUCKET_FORK_BODY.format(
         display_name=payload["actor"]["displayName"],
-        username=payload["actor"]["name"],
+        username=get_user_name(payload),
         fork_name=payload["repository"]["name"],
         fork_url=payload["repository"]["links"]["self"][0]["href"]
     )
@@ -46,7 +74,7 @@ def repo_forked_handler(payload: Dict[str, Any]) -> List[Dict[str, str]]:
 def repo_modified_handler(payload: Dict[str, Any]) -> List[Dict[str, str]]:
     subject_new = BITBUCKET_TOPIC_TEMPLATE.format(repository_name=payload["new"]["name"])
     body = BITBUCKET_REPO_UPDATED_CHANGED.format(
-        actor=payload["actor"]["name"],
+        actor=get_user_name(payload),
         change="name",
         repo_name=payload["old"]["name"],
         old=payload["old"]["name"],
@@ -57,16 +85,22 @@ def repo_modified_handler(payload: Dict[str, Any]) -> List[Dict[str, str]]:
 def repo_push_branch_data(payload: Dict[str, Any], change: Dict[str, Any]) -> Dict[str, str]:
     event_type = change["type"]
     repo_name = payload["repository"]["name"]
-    user_name = payload["actor"]["name"]
+    user_name = get_user_name(payload)
     branch_name = change["ref"]["displayId"]
     branch_head = change["toHash"]
 
     if event_type == "ADD":
-        body = get_create_branch_event_message(user_name=user_name, url=None, branch_name=branch_name)
+        body = get_create_branch_event_message(
+            user_name=user_name,
+            url=None,
+            branch_name=branch_name
+        )
     elif event_type == "UPDATE":
-        body = BRANCH_UPDATED_MESSAGE_TEMPLATE.format(user_name=user_name,
-                                                      branch_name=branch_name,
-                                                      head=branch_head)
+        body = BRANCH_UPDATED_MESSAGE_TEMPLATE.format(
+            user_name=user_name,
+            branch_name=branch_name,
+            head=branch_head
+        )
     elif event_type == "DELETE":
         body = get_remove_branch_event_message(user_name, branch_name)
     else:
@@ -80,7 +114,6 @@ def repo_push_tag_data(payload: Dict[str, Any], change: Dict[str, Any]) -> Dict[
     event_type = change["type"]
     repo_name = payload["repository"]["name"]
     tag_name = change["ref"]["displayId"]
-    user_name = payload["actor"]["name"]
 
     if event_type == "ADD":
         action = "pushed"
@@ -91,13 +124,11 @@ def repo_push_tag_data(payload: Dict[str, Any], change: Dict[str, Any]) -> Dict[
         raise UnexpectedWebhookEventType("BitBucket Server", message)
 
     subject = BITBUCKET_TOPIC_TEMPLATE.format(repository_name=repo_name)
-    body = get_push_tag_event_message(
-        user_name,
-        tag_name,
-        action=action)
+    body = get_push_tag_event_message(get_user_name(payload), tag_name, action=action)
     return {"subject": subject, "body": body}
 
-def repo_push_handler(payload: Dict[str, Any], branches: Optional[str]=None) -> List[Dict[str, str]]:
+def repo_push_handler(payload: Dict[str, Any], branches: Optional[str]=None
+                      ) -> List[Dict[str, str]]:
     data = []
     for change in payload["changes"]:
         event_target_type = change["ref"]["type"]
@@ -114,6 +145,152 @@ def repo_push_handler(payload: Dict[str, Any], branches: Optional[str]=None) ->
             raise UnexpectedWebhookEventType("BitBucket Server", message)
     return data
 
+def get_assignees_string(pr: Dict[str, Any]) -> Optional[str]:
+    reviewers = []
+    for reviewer in pr["reviewers"]:
+        name = reviewer["user"]["name"]
+        link = reviewer["user"]["links"]["self"][0]["href"]
+        reviewers.append("[%s](%s)" % (name, link))
+    if len(reviewers) == 0:
+        assignees = None
+    elif len(reviewers) == 1:
+        assignees = reviewers[0]
+    else:
+        assignees = ", ".join(reviewers[:-1]) + " and " + reviewers[-1]
+    return assignees
+
+def get_pr_subject(repo: str, type: str, id: str, title: str) -> str:
+    return TOPIC_WITH_PR_OR_ISSUE_INFO_TEMPLATE.format(repo=repo, type=type, id=id, title=title)
+
+def get_simple_pr_body(payload: Dict[str, Any], action: str, include_title: Optional[bool]) -> str:
+    pr = payload["pullRequest"]
+    return get_pull_request_event_message(
+        user_name=get_user_name(payload),
+        action=action,
+        url=pr["links"]["self"][0]["href"],
+        number=pr["id"],
+        title=pr["title"] if include_title else None
+    )
+
+def get_pr_opened_or_modified_body(payload: Dict[str, Any], action: str,
+                                   include_title: Optional[bool]) -> str:
+    pr = payload["pullRequest"]
+    description = pr.get("description")
+    assignees_string = get_assignees_string(pr)
+    if assignees_string:
+        # Then use the custom message template for this particular integration so that we can
+        # specify the reviewers at the end of the message (but before the description/message).
+        parameters = {"user_name": get_user_name(payload),
+                      "action": action,
+                      "url": pr["links"]["self"][0]["href"],
+                      "number": pr["id"],
+                      "source": pr["fromRef"]["displayId"],
+                      "destination": pr["toRef"]["displayId"],
+                      "message": description,
+                      "assignees": assignees_string,
+                      "title": pr["title"] if include_title else None}
+        if include_title:
+            body = PULL_REQUEST_OPENED_OR_MODIFIED_TEMPLATE_WITH_REVIEWERS_WITH_TITLE.format(
+                **parameters
+            )
+        else:
+            body = PULL_REQUEST_OPENED_OR_MODIFIED_TEMPLATE_WITH_REVIEWERS.format(**parameters)
+        if description:
+            body += '\n' + CONTENT_MESSAGE_TEMPLATE.format(message=description)
+        return body
+    return get_pull_request_event_message(
+        user_name=get_user_name(payload),
+        action=action,
+        url=pr["links"]["self"][0]["href"],
+        number=pr["id"],
+        target_branch=pr["fromRef"]["displayId"],
+        base_branch=pr["toRef"]["displayId"],
+        message=pr.get("description"),
+        assignee=assignees_string if assignees_string else None,
+        title=pr["title"] if include_title else None
+    )
+
+def get_pr_needs_work_body(payload: Dict[str, Any], include_title: Optional[bool]) -> str:
+    pr = payload["pullRequest"]
+    if not include_title:
+        return PULL_REQUEST_MARKED_AS_NEEDS_WORK_TEMPLATE.format(
+            user_name=get_user_name(payload),
+            number=pr["id"],
+            url=pr["links"]["self"][0]["href"]
+        )
+    return PULL_REQUEST_MARKED_AS_NEEDS_WORK_TEMPLATE_WITH_TITLE.format(
+        user_name=get_user_name(payload),
+        number=pr["id"],
+        url=pr["links"]["self"][0]["href"],
+        title=pr["title"]
+    )
+
+def get_pr_reassigned_body(payload: Dict[str, Any], include_title: Optional[bool]) -> str:
+    pr = payload["pullRequest"]
+    assignees_string = get_assignees_string(pr)
+    if not assignees_string:
+        if not include_title:
+            return PULL_REQUEST_REASSIGNED_TO_NONE_TEMPLATE.format(
+                user_name=get_user_name(payload),
+                number=pr["id"],
+                url=pr["links"]["self"][0]["href"]
+            )
+        return PULL_REQUEST_REASSIGNED_TO_NONE_TEMPLATE_WITH_TITLE.format(
+            user_name=get_user_name(payload),
+            number=pr["id"],
+            url=pr["links"]["self"][0]["href"],
+            title=pr["title"]
+        )
+    if not include_title:
+        return PULL_REQUEST_REASSIGNED_TEMPLATE.format(
+            user_name=get_user_name(payload),
+            number=pr["id"],
+            url=pr["links"]["self"][0]["href"],
+            assignees=assignees_string
+        )
+    return PULL_REQUEST_REASSIGNED_TEMPLATE_WITH_TITLE.format(
+        user_name=get_user_name(payload),
+        number=pr["id"],
+        url=pr["links"]["self"][0]["href"],
+        assignees=assignees_string,
+        title=pr["title"]
+    )
+
+def pr_handler(payload: Dict[str, Any], action: str,
+               include_title: Optional[bool]=False) -> List[Dict[str, str]]:
+    pr = payload["pullRequest"]
+    subject = get_pr_subject(pr["toRef"]["repository"]["name"], type="PR", id=pr["id"],
+                             title=pr["title"])
+    if action in ["opened", "modified"]:
+        body = get_pr_opened_or_modified_body(payload, action, include_title)
+    elif action == "needs_work":
+        body = get_pr_needs_work_body(payload, include_title)
+    elif action == "reviewers_updated":
+        body = get_pr_reassigned_body(payload, include_title)
+    else:
+        body = get_simple_pr_body(payload, action, include_title)
+
+    return [{"subject": subject, "body": body}]
+
+def pr_comment_handler(payload: Dict[str, Any], action: str,
+                       include_title: Optional[bool]=False) -> List[Dict[str, str]]:
+    pr = payload["pullRequest"]
+    subject = get_pr_subject(pr["toRef"]["repository"]["name"], type="PR", id=pr["id"],
+                             title=pr["title"])
+    message = payload["comment"]["text"]
+    if action == "deleted their comment on":
+        message = "~~{message}~~".format(message=message)
+    body = get_pull_request_event_message(
+        user_name=get_user_name(payload),
+        action=action,
+        url=pr["links"]["self"][0]["href"],
+        number=pr["id"],
+        message=message,
+        title=pr["title"] if include_title else None
+    )
+
+    return [{"subject": subject, "body": body}]
+
 EVENT_HANDLER_MAP = {
     "repo:comment:added": partial(repo_comment_handler, action="commented"),
     "repo:comment:edited": partial(repo_comment_handler, action="edited their comment"),
@@ -121,18 +298,18 @@ def repo_push_handler(payload: Dict[str, Any], branches: Optional[str]=None) ->
     "repo:forked": repo_forked_handler,
     "repo:modified": repo_modified_handler,
     "repo:refs_changed": repo_push_handler,
-    "pr:comment:added": None,
-    "pr:comment:edited": None,
-    "pr:comment:deleted": None,
-    "pr:declined": None,
-    "pr:deleted": None,
-    "pr:merged": None,
-    "pr:modified": None,
-    "pr:opened": None,
-    "pr:reviewer:approved": None,
-    "pr:reviewer:needs_work": None,
-    "pr:reviewer:updated": None,
-    "pr:reviewer:unapproved": None,
+    "pr:comment:added": partial(pr_comment_handler, action="commented on"),
+    "pr:comment:edited": partial(pr_comment_handler, action="edited their comment on"),
+    "pr:comment:deleted": partial(pr_comment_handler, action="deleted their comment on"),
+    "pr:declined": partial(pr_handler, action="declined"),
+    "pr:deleted": partial(pr_handler, action="deleted"),
+    "pr:merged": partial(pr_handler, action="merged"),
+    "pr:modified": partial(pr_handler, action="modified"),
+    "pr:opened": partial(pr_handler, action="opened"),
+    "pr:reviewer:approved": partial(pr_handler, action="approved"),
+    "pr:reviewer:needs_work": partial(pr_handler, action="needs_work"),
+    "pr:reviewer:updated": partial(pr_handler, action="reviewers_updated"),
+    "pr:reviewer:unapproved": partial(pr_handler, action="unapproved"),
 }  # type Dict[str, Optional[Callable[..., List[Dict[str, str]]]]]
 
 def get_event_handler(eventkey: str) -> Callable[..., List[Dict[str, str]]]:
@@ -154,6 +331,8 @@ def api_bitbucket3_webhook(request: HttpRequest, user_profile: UserProfile,
 
     if "branches" in signature(handler).parameters:
         data = handler(payload, branches)
+    elif "include_title" in signature(handler).parameters:
+        data = handler(payload, include_title=user_specified_topic)
     else:
         data = handler(payload)
     for element in data:


Test patch:
diff --git a/zerver/webhooks/bitbucket3/tests.py b/zerver/webhooks/bitbucket3/tests.py
--- a/zerver/webhooks/bitbucket3/tests.py
+++ b/zerver/webhooks/bitbucket3/tests.py
@@ -8,80 +8,71 @@ class Bitbucket3HookTests(WebhookTestCase):
     EXPECTED_TOPIC = "sandbox"
     EXPECTED_TOPIC_BRANCH_EVENTS = "sandbox / {branch}"
 
+    # Core Repo Events:
     def test_commit_comment_added(self) -> None:
-        expected_message = """hypro999 commented on [508d1b6](http://139.59.64.214:7990/projects\
-/SBOX/repos/sandbox/commits/508d1b67f1f8f3a25f543a030a7a178894aa9907)\n~~~ quote\nJust an \
-arbitrary comment on a commit.\n~~~"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) commented on [508d1b6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/commits/508d1b67f1f8f3a25f543a030a7a178894aa9907)\n~~~ quote\nJust an arbitrary comment on a commit.\n~~~"""
         self.send_and_test_stream_message("commit_comment_added",
                                           self.EXPECTED_TOPIC,
                                           expected_message)
 
     def test_commit_comment_edited(self) -> None:
-        expected_message = """hypro999 edited their comment on [508d1b6](http://139.59.64.214:7990\
-/projects/SBOX/repos/sandbox/commits/508d1b67f1f8f3a25f543a030a7a178894aa9907)\n~~~ quote\nJust \
-an arbitrary comment on a commit. Nothing to see here...\n~~~"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) edited their comment on [508d1b6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/commits/508d1b67f1f8f3a25f543a030a7a178894aa9907)\n~~~ quote\nJust an arbitrary comment on a commit. Nothing to see here...\n~~~"""
         self.send_and_test_stream_message("commit_comment_edited",
                                           self.EXPECTED_TOPIC,
                                           expected_message)
 
     def test_commit_comment_deleted(self) -> None:
-        expected_message = """hypro999 deleted their comment on [508d1b6]\
-(http://139.59.64.214:7990/projects/SBOX/repos/sandbox/commits/508d1b67f1f8f3a25f543a030a7a178894a\
-a9907)\n~~~ quote\nJust an arbitrary comment on a commit. Nothing to see here...\n~~~"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) deleted their comment on [508d1b6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/commits/508d1b67f1f8f3a25f543a030a7a178894aa9907)\n~~~ quote\n~~Just an arbitrary comment on a commit. Nothing to see here...~~\n~~~"""
         self.send_and_test_stream_message("commit_comment_deleted",
                                           self.EXPECTED_TOPIC,
                                           expected_message)
 
     def test_bitbucket3_repo_forked(self) -> None:
-        expected_message = """User Hemanth V. Alluri(login: hypro999) forked the repository into \
-[sandbox fork](http://139.59.64.214:7990/users/hypro999/repos/sandbox-fork/browse)."""
+        expected_message = """User Hemanth V. Alluri(login: [hypro999](http://139.59.64.214:7990/users/hypro999)) forked the repository into [sandbox fork](http://139.59.64.214:7990/users/hypro999/repos/sandbox-fork/browse)."""
         self.send_and_test_stream_message("repo_forked", self.EXPECTED_TOPIC, expected_message)
 
     def test_bitbucket3_repo_modified(self) -> None:
-        expected_message = """hypro999 changed the name of the **sandbox** repo from **sandbox** \
-to **sandbox v2**"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) changed the name of the **sandbox** repo from **sandbox** to **sandbox v2**"""
         expected_topic = "sandbox v2"
         self.send_and_test_stream_message("repo_modified", expected_topic, expected_message)
 
+    # Repo Push Events:
     def test_push_add_branch(self) -> None:
-        expected_message = """hypro999 created branch2 branch"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) created branch2 branch"""
         expected_topic = self.EXPECTED_TOPIC_BRANCH_EVENTS.format(branch="branch2")
         self.send_and_test_stream_message("repo_push_add_branch",
                                           expected_topic,
                                           expected_message)
 
     def test_push_add_tag(self) -> None:
-        expected_message = """hypro999 pushed tag newtag"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) pushed tag newtag"""
         self.send_and_test_stream_message("repo_push_add_tag",
                                           self.EXPECTED_TOPIC,
                                           expected_message)
 
     def test_push_delete_branch(self) -> None:
-        expected_message = """hypro999 deleted branch branch2"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) deleted branch branch2"""
         expected_topic = self.EXPECTED_TOPIC_BRANCH_EVENTS.format(branch="branch2")
         self.send_and_test_stream_message("repo_push_delete_branch",
                                           expected_topic,
                                           expected_message)
 
     def test_push_delete_tag(self) -> None:
-        expected_message = """hypro999 removed tag test-tag"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) removed tag test-tag"""
         self.send_and_test_stream_message("repo_push_delete_tag",
                                           self.EXPECTED_TOPIC,
                                           expected_message)
 
     def test_push_update_single_branch(self) -> None:
-        expected_message = """hypro999 pushed to branch master. Head is now \
-e68c981ef53dbab0a5ca320a2d8d80e216c70528"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) pushed to branch master. Head is now e68c981ef53dbab0a5ca320a2d8d80e216c70528."""
         expected_topic = self.EXPECTED_TOPIC_BRANCH_EVENTS.format(branch="master")
         self.send_and_test_stream_message("repo_push_update_single_branch",
                                           expected_topic,
                                           expected_message)
 
     def test_push_update_multiple_branches(self) -> None:
-        expected_message_first = """hypro999 pushed to branch branch1. Head is now \
-3980c2be32a7e23c795741d5dc1a2eecb9b85d6d"""
-        expected_message_second = """hypro999 pushed to branch master. Head is now \
-fc43d13cff1abb28631196944ba4fc4ad06a2cf2"""
+        expected_message_first = """[hypro999](http://139.59.64.214:7990/users/hypro999) pushed to branch branch1. Head is now 3980c2be32a7e23c795741d5dc1a2eecb9b85d6d."""
+        expected_message_second = """[hypro999](http://139.59.64.214:7990/users/hypro999) pushed to branch master. Head is now fc43d13cff1abb28631196944ba4fc4ad06a2cf2."""
         self.send_and_test_stream_message("repo_push_update_multiple_branches")
 
         msg = self.get_last_message()
@@ -94,17 +85,187 @@ def test_push_update_multiple_branches(self) -> None:
 
     def test_push_update_multiple_branches_with_branch_filter(self) -> None:
         self.url = self.build_webhook_url(branches='master')
-        expected_message = """hypro999 pushed to branch master. Head is now \
-fc43d13cff1abb28631196944ba4fc4ad06a2cf2"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) pushed to branch master. Head is now fc43d13cff1abb28631196944ba4fc4ad06a2cf2."""
         expected_topic = self.EXPECTED_TOPIC_BRANCH_EVENTS.format(branch="master")
         self.send_and_test_stream_message("repo_push_update_multiple_branches",
                                           expected_topic,
                                           expected_message)
 
         self.url = self.build_webhook_url(branches='branch1')
-        expected_message = """hypro999 pushed to branch branch1. Head is now \
-3980c2be32a7e23c795741d5dc1a2eecb9b85d6d"""
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) pushed to branch branch1. Head is now 3980c2be32a7e23c795741d5dc1a2eecb9b85d6d."""
         expected_topic = self.EXPECTED_TOPIC_BRANCH_EVENTS.format(branch="branch1")
         self.send_and_test_stream_message("repo_push_update_multiple_branches",
                                           expected_topic,
                                           expected_message)
+
+    # Core PR Events:
+    def test_pr_opened_without_reviewers(self) -> None:
+        expected_topic = "sandbox / PR #1 Branch1"
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) opened [PR #1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1)\nfrom `branch1` to `master`\n\n~~~ quote\n* Add file2.txt\r\n* Add file3.txt\n~~~"""
+        self.send_and_test_stream_message("pull_request_opened_without_reviewers",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_opened_without_description(self) -> None:
+        expected_topic = "sandbox / PR #2 Add notes feature."
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) opened [PR #2](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/2)\nfrom `master` to `master`"""
+        self.send_and_test_stream_message("pull_request_opened_without_description",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_opened_with_two_reviewers(self) -> None:
+        expected_topic = "sandbox / PR #5 Add Notes Feature"
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) opened [PR #5](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/5)\nfrom `master` to `master` (assigned to [shimura](http://139.59.64.214:7990/users/shimura) and [sougo](http://139.59.64.214:7990/users/sougo) for review)"""
+        self.send_and_test_stream_message("pull_request_opened_with_two_reviewers",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_opened_with_two_reviewers_and_user_defined_topic(self) -> None:
+        expected_topic = "sandbox / PR #5 Add Notes Feature"
+        expected_topic = "custom_topic"
+        self.url = self.build_webhook_url(topic='custom_topic')
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) opened [PR #5 Add Notes Feature](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/5)\nfrom `master` to `master` (assigned to [shimura](http://139.59.64.214:7990/users/shimura) and [sougo](http://139.59.64.214:7990/users/sougo) for review)"""
+        self.send_and_test_stream_message("pull_request_opened_with_two_reviewers",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_opened_with_mulitple_reviewers(self) -> None:
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) opened [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6)\nfrom `master` to `master` (assigned to [sougo](http://139.59.64.214:7990/users/sougo), [zura](http://139.59.64.214:7990/users/zura) and [shimura](http://139.59.64.214:7990/users/shimura) for review)\n\n~~~ quote\nAdd a simple text file for further testing purposes.\n~~~"""
+        self.send_and_test_stream_message("pull_request_opened_with_multiple_reviewers",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_modified(self) -> None:
+        expected_topic = "sandbox / PR #1 Branch1"
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) modified [PR #1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1)\nfrom `branch1` to `master` (assigned to [shimura](http://139.59.64.214:7990/users/shimura) for review)\n\n~~~ quote\n* Add file2.txt\n* Add file3.txt\nBoth of these files would be important additions to the project!\n~~~"""
+        self.send_and_test_stream_message("pull_request_modified",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_modified_with_include_title(self) -> None:
+        expected_topic = "custom_topic"
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) modified [PR #1 Branch1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1)\nfrom `branch1` to `master` (assigned to [shimura](http://139.59.64.214:7990/users/shimura) for review)\n\n~~~ quote\n* Add file2.txt\n* Add file3.txt\nBoth of these files would be important additions to the project!\n~~~"""
+        self.url = self.build_webhook_url(topic='custom_topic')
+        self.send_and_test_stream_message("pull_request_modified",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_deleted(self) -> None:
+        expected_topic = "sandbox / PR #2 Add notes feature."
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) deleted [PR #2](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/2)"""
+        self.send_and_test_stream_message("pull_request_deleted",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_deleted_with_include_title(self) -> None:
+        expected_topic = "custom_topic"
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) deleted [PR #2 Add notes feature.](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/2)"""
+        self.url = self.build_webhook_url(topic='custom_topic')
+        self.send_and_test_stream_message("pull_request_deleted",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_declined(self) -> None:
+        expected_topic = "sandbox / PR #7 Crazy Idea"
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) declined [PR #7](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/7)"""
+        self.send_and_test_stream_message("pull_request_declined",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_merged(self) -> None:
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) merged [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6)"""
+        self.send_and_test_stream_message("pull_request_merged",
+                                          expected_topic,
+                                          expected_message)
+
+    # PR Reviewer Events:
+    def test_pr_approved(self) -> None:
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) approved [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6)"""
+        self.send_and_test_stream_message("pull_request_approved",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_unapproved(self) -> None:
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) unapproved [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6)"""
+        self.send_and_test_stream_message("pull_request_unapproved",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_marked_as_needs_review(self) -> None:
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) marked [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6) as \"needs work\""""
+        self.send_and_test_stream_message("pull_request_needs_work",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pr_marked_as_needs_review_and_include_title(self) -> None:
+        expected_topic = "custom_topic"
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) marked [PR #6 sample_file: Add sample_file.txt.](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6) as \"needs work\""""
+        self.url = self.build_webhook_url(topic='custom_topic')
+        self.send_and_test_stream_message("pull_request_needs_work",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pull_request_reviewer_added(self) -> None:
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) reassigned [PR #1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1) to [shimura](http://139.59.64.214:7990/users/shimura)"""
+        expected_topic = "sandbox / PR #1 Branch1"
+        self.send_and_test_stream_message("pull_request_add_reviewer",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pull_request_reviewer_added_and_include_title(self) -> None:
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) reassigned [PR #1 Branch1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1) to [shimura](http://139.59.64.214:7990/users/shimura)"""
+        expected_topic = "custom_topic"
+        self.url = self.build_webhook_url(topic='custom_topic')
+        self.send_and_test_stream_message("pull_request_add_reviewer",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pull_request_reviewers_added(self) -> None:
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) reassigned [PR #1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1) to [shimura](http://139.59.64.214:7990/users/shimura) and [sougo](http://139.59.64.214:7990/users/sougo)"""
+        expected_topic = "sandbox / PR #1 Branch1"
+        self.send_and_test_stream_message("pull_request_add_two_reviewers",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pull_request_remove_all_reviewers(self) -> None:
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) removed all reviewers from [PR #1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1)"""
+        expected_topic = "sandbox / PR #1 Branch1"
+        self.send_and_test_stream_message("pull_request_remove_reviewer",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pull_request_remove_all_reviewers_with_title(self) -> None:
+        expected_message = """[hypro999](http://139.59.64.214:7990/users/hypro999) removed all reviewers from [PR #1 Branch1](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/1)"""
+        expected_topic = "sandbox / PR #1 Branch1"
+        expected_topic = "custom_topic"
+        self.url = self.build_webhook_url(topic='custom_topic')
+        self.send_and_test_stream_message("pull_request_remove_reviewer",
+                                          expected_topic,
+                                          expected_message)
+
+    # PR Comment Events:
+    def test_pull_request_comment_added(self) -> None:
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) commented on [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6)\n\n~~~ quote\nThis seems like a pretty good idea.\n~~~"""
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        self.send_and_test_stream_message("pull_request_comment_added",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pull_request_comment_edited(self) -> None:
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) edited their comment on [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6)\n\n~~~ quote\nThis seems like a pretty good idea. @shimura what do you think?\n~~~"""
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        self.send_and_test_stream_message("pull_request_comment_edited",
+                                          expected_topic,
+                                          expected_message)
+
+    def test_pull_request_comment_deleted(self) -> None:
+        expected_message = """[zura](http://139.59.64.214:7990/users/zura) deleted their comment on [PR #6](http://139.59.64.214:7990/projects/SBOX/repos/sandbox/pull-requests/6)\n\n~~~ quote\n~~This seems like a pretty good idea. @shimura what do you think?~~\n~~~"""
+        expected_topic = "sandbox / PR #6 sample_file: Add sample_file.txt."
+        self.send_and_test_stream_message("pull_request_comment_deleted",
+                                          expected_topic,
+                                          expected_message)