Repository: zulip/zulip

Issue:
Make clubhouse integration handle multiple relevant secondary actions
Each clubhouse payload has a primary action and a bunch of secondary actions. The primary action can be for example creating a pull request and the secondary action can be including the pull request to a story. So for each payload the clubhouse integration would send a message to a stream. Eg when a pull request is created and added to a story a message would be send under the topic Story name that the pull request has been created.

Our integration currently assumes that there is only one relevant secondary action to a primary action. For example, in case of creating a new pull request(primary action), it assumes that the pull request would be added(secondary action) only to one story. So the action sends a message to the topic story name and with body containing the pull request details.

This is not fully correct since there can be multiple stories associated with a given pull request[1][2].

![Screenshot from 2021-04-07 18-17-09](https://user-images.githubusercontent.com/7190633/113868613-7d3a6180-97cd-11eb-8b69-893d21df33dc.png)


The current implementation of the clubhouse integration sends message only to one story topic instead of sending messages to all the story topics.

<details>
<summary>Click to show JSON payload</summary>

```json
{
    "id":"70686928-f649-607f-b640-8b80310559f4",
    "changed_at":"2021-04-02T18:57:41.201Z",
    "version":"v1",
    "primary_id":700000036,
    "member_id":"20576d78-5b7a-5f88-b59c-a67506a49fa8",
    "actions":[
       {
          "id":700000036,
          "entity_type":"pull-request",
          "action":"create",
          "number":2,
          "title":"Test 2",
          "url":"https://github.com/hackerkid/test/pull/2"
       },
       {
          "id":12,
          "entity_type":"story",
          "action":"update",
          "name":"test story 1",
          "story_type":"feature",
          "app_url":"https://app.clubhouse.io/hackerkid/story/12",
          "changes":{
             "pull_request_ids":{
                "adds":[
                   700000036
                ]
             },
             "started":{
                "new":true,
                "old":false
             },
             "position":{
                "new":42147680257,
                "old":12547680256
             },
             "workflow_state_id":{
                "new":500000006,
                "old":500000008
             },
             "started_at":{
                "new":"2021-04-02T18:57:41Z"
             },
             "owner_ids":{
                "adds":[
                   "20576d78-5b7a-5f88-b59c-a67506a49fa8"
                ]
             }
          }
       },
       {
          "id":10,
          "entity_type":"story",
          "action":"update",
          "name":"www",
          "story_type":"feature",
          "app_url":"https://app.clubhouse.io/hackerkid/story/10",
          "changes":{
             "pull_request_ids":{
                "adds":[
                   700000036
                ]
             },
             "started":{
                "new":true,
                "old":false
             },
             "position":{
                "new":32147549184,
                "old":12147549184
             },
             "workflow_state_id":{
                "new":500000006,
                "old":500000008
             },
             "started_at":{
                "new":"2021-04-02T18:57:41Z"
             },
             "owner_ids":{
                "adds":[
                   "20576d78-5b7a-5f88-b59c-a67506a49fa8"
                ]
             }
          }
       }
    ],
    "references":[
       {
          "id":500000006,
          "entity_type":"workflow-state",
          "name":"In Development",
          "type":"started"
       },
       {
          "id":500000008,
          "entity_type":"workflow-state",
          "name":"Unscheduled",
          "type":"unstarted"
       }
    ]
 }
 ```
</details>


![Screenshot from 2021-04-07 18-16-03](https://user-images.githubusercontent.com/7190633/113868486-5845ee80-97cd-11eb-9f93-a9a0a44c2454.png)


The pull request is just one example of how the integration fails in handling multiple secondary actions. There should be other cases as well. I have not investigated the other scerios.

We should rewrite the clubhouse integration to make sure that all the pull requests are handled.

[1]https://help.clubhouse.io/hc/en-us/articles/207540323-Using-Branches-and-Pull-Requests-with-the-Clubhouse-VCS-Integrations
[2]https://github.com/hackerkid/test/pull/2




Discussion:
Hello @zulip/server-integrations members, this issue was labeled with the "area: integrations" label, so you may want to check it out!

<!-- areaLabelAddition -->
@zulipbot claim

Resolving patch:
diff --git a/zerver/webhooks/clubhouse/view.py b/zerver/webhooks/clubhouse/view.py
--- a/zerver/webhooks/clubhouse/view.py
+++ b/zerver/webhooks/clubhouse/view.py
@@ -1,5 +1,5 @@
 from functools import partial
-from typing import Any, Callable, Dict, Optional
+from typing import Any, Callable, Dict, Generator, List, Optional
 
 from django.http import HttpRequest, HttpResponse
 
@@ -30,20 +30,22 @@
     "The name of the {entity} {name_template} was changed from:\n"
     "``` quote\n{old}\n```\nto\n``` quote\n{new}\n```"
 )
-ARCHIVED_TEMPLATE = "The {entity} {name_template} was {action}."
-STORY_TASK_TEMPLATE = "Task **{task_description}** was {action} the story {name_template}."
+ARCHIVED_TEMPLATE = "The {entity} {name_template} was {operation}."
+STORY_TASK_TEMPLATE = "Task **{task_description}** was {operation} the story {name_template}."
 STORY_TASK_COMPLETED_TEMPLATE = (
     "Task **{task_description}** ({name_template}) was completed. :tada:"
 )
 STORY_ADDED_REMOVED_EPIC_TEMPLATE = (
-    "The story {story_name_template} was {action} the epic {epic_name_template}."
+    "The story {story_name_template} was {operation} the epic {epic_name_template}."
 )
 STORY_EPIC_CHANGED_TEMPLATE = "The story {story_name_template} was moved from {old_epic_name_template} to {new_epic_name_template}."
 STORY_ESTIMATE_TEMPLATE = "The estimate for the story {story_name_template} was set to {estimate}."
 FILE_ATTACHMENT_TEMPLATE = (
     "A {type} attachment `{file_name}` was added to the story {name_template}."
 )
-STORY_LABEL_TEMPLATE = "The label **{label_name}** was added to the story {name_template}."
+LABEL_TEMPLATE = "**{name}**"
+STORY_LABEL_TEMPLATE = "The label {labels} was added to the story {name_template}."
+STORY_LABEL_PLURAL_TEMPLATE = "The labels {labels} were added to the story {name_template}."
 STORY_UPDATE_PROJECT_TEMPLATE = (
     "The story {name_template} was moved from the **{old}** project to **{new}**."
 )
@@ -52,12 +54,16 @@
 )
 DELETE_TEMPLATE = "The {entity_type} **{name}** was deleted."
 STORY_UPDATE_OWNER_TEMPLATE = "New owner added to the story {name_template}."
+TRAILING_WORKFLOW_STATE_CHANGE_TEMPLATE = " ({old} -> {new})"
 STORY_GITHUB_PR_TEMPLATE = (
-    "New GitHub PR [#{name}]({url}) opened for story {name_template} ({old} -> {new})."
-)
-STORY_GITHUB_BRANCH_TEMPLATE = (
-    "New GitHub branch [{name}]({url}) associated with story {name_template} ({old} -> {new})."
+    "New GitHub PR [#{name}]({url}) opened for story {name_template}{workflow_state_template}."
 )
+STORY_GITHUB_COMMENT_PR_TEMPLATE = "Existing GitHub PR [#{name}]({url}) associated with story {name_template}{workflow_state_template}."
+STORY_GITHUB_BRANCH_TEMPLATE = "New GitHub branch [{name}]({url}) associated with story {name_template}{workflow_state_template}."
+STORY_UPDATE_BATCH_TEMPLATE = "The story {name_template} {templates}{workflow_state_template}."
+STORY_UPDATE_BATCH_CHANGED_TEMPLATE = "{operation} from {sub_templates}"
+STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE = "{entity_type} **{old}** to **{new}**"
+STORY_UPDATE_BATCH_ADD_REMOVE_TEMPLATE = "{operation} with {entity}"
 
 
 def get_action_with_primary_id(payload: Dict[str, Any]) -> Dict[str, Any]:
@@ -68,10 +74,13 @@ def get_action_with_primary_id(payload: Dict[str, Any]) -> Dict[str, Any]:
     return action_with_primary_id
 
 
-def get_event(payload: Dict[str, Any]) -> Optional[str]:
-    action = get_action_with_primary_id(payload)
+def get_event(payload: Dict[str, Any], action: Dict[str, Any]) -> Optional[str]:
     event = "{}_{}".format(action["entity_type"], action["action"])
 
+    # We only consider the change to be a batch update only if there are multiple stories (thus there is no primary_id)
+    if event == "story_update" and payload.get("primary_id") is None:
+        return "{}_{}".format(event, "batch")
+
     if event in IGNORED_EVENTS:
         return None
 
@@ -107,19 +116,16 @@ def get_event(payload: Dict[str, Any]) -> Optional[str]:
     return event
 
 
-def get_topic_function_based_on_type(payload: Dict[str, Any]) -> Any:
-    entity_type = get_action_with_primary_id(payload)["entity_type"]
+def get_topic_function_based_on_type(payload: Dict[str, Any], action: Dict[str, Any]) -> Any:
+    entity_type = action["entity_type"]
     return EVENT_TOPIC_FUNCTION_MAPPER.get(entity_type)
 
 
-def get_delete_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
+def get_delete_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     return DELETE_TEMPLATE.format(**action)
 
 
-def get_story_create_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
-
+def get_story_create_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     if action.get("epic_id") is None:
         message = "New story [{name}]({app_url}) of type **{story_type}** was created."
         kwargs = action
@@ -138,13 +144,12 @@ def get_story_create_body(payload: Dict[str, Any]) -> str:
     return message.format(**kwargs)
 
 
-def get_epic_create_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
+def get_epic_create_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     message = "New epic **{name}**({state}) was created."
     return message.format(**action)
 
 
-def get_comment_added_body(payload: Dict[str, Any], entity: str) -> str:
+def get_comment_added_body(payload: Dict[str, Any], action: Dict[str, Any], entity: str) -> str:
     actions = payload["actions"]
     kwargs = {"entity": entity}
     for action in actions:
@@ -160,8 +165,9 @@ def get_comment_added_body(payload: Dict[str, Any], entity: str) -> str:
     return COMMENT_ADDED_TEMPLATE.format(**kwargs)
 
 
-def get_update_description_body(payload: Dict[str, Any], entity: str) -> str:
-    action = get_action_with_primary_id(payload)
+def get_update_description_body(
+    payload: Dict[str, Any], action: Dict[str, Any], entity: str
+) -> str:
     desc = action["changes"]["description"]
 
     kwargs = {
@@ -184,8 +190,7 @@ def get_update_description_body(payload: Dict[str, Any], entity: str) -> str:
     return body
 
 
-def get_epic_update_state_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
+def get_epic_update_state_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     state = action["changes"]["state"]
     kwargs = {
         "entity": "epic",
@@ -197,8 +202,7 @@ def get_epic_update_state_body(payload: Dict[str, Any]) -> str:
     return STATE_CHANGED_TEMPLATE.format(**kwargs)
 
 
-def get_story_update_state_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
+def get_story_update_state_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     workflow_state_id = action["changes"]["workflow_state_id"]
     references = payload["references"]
 
@@ -222,8 +226,7 @@ def get_story_update_state_body(payload: Dict[str, Any]) -> str:
     return STATE_CHANGED_TEMPLATE.format(**kwargs)
 
 
-def get_update_name_body(payload: Dict[str, Any], entity: str) -> str:
-    action = get_action_with_primary_id(payload)
+def get_update_name_body(payload: Dict[str, Any], action: Dict[str, Any], entity: str) -> str:
     name = action["changes"]["name"]
     kwargs = {
         "entity": entity,
@@ -238,32 +241,29 @@ def get_update_name_body(payload: Dict[str, Any], entity: str) -> str:
     return NAME_CHANGED_TEMPLATE.format(**kwargs)
 
 
-def get_update_archived_body(payload: Dict[str, Any], entity: str) -> str:
-    primary_action = get_action_with_primary_id(payload)
-    archived = primary_action["changes"]["archived"]
+def get_update_archived_body(payload: Dict[str, Any], action: Dict[str, Any], entity: str) -> str:
+    archived = action["changes"]["archived"]
     if archived["new"]:
-        action = "archived"
+        operation = "archived"
     else:
-        action = "unarchived"
+        operation = "unarchived"
 
     kwargs = {
         "entity": entity,
         "name_template": get_name_template(entity).format(
-            name=primary_action["name"],
-            app_url=primary_action.get("app_url"),
+            name=action["name"],
+            app_url=action.get("app_url"),
         ),
-        "action": action,
+        "operation": operation,
     }
 
     return ARCHIVED_TEMPLATE.format(**kwargs)
 
 
-def get_story_task_body(payload: Dict[str, Any], action: str) -> str:
-    primary_action = get_action_with_primary_id(payload)
-
+def get_story_task_body(payload: Dict[str, Any], action: Dict[str, Any], operation: str) -> str:
     kwargs = {
-        "task_description": primary_action["description"],
-        "action": action,
+        "task_description": action["description"],
+        "operation": operation,
     }
 
     for a in payload["actions"]:
@@ -276,9 +276,7 @@ def get_story_task_body(payload: Dict[str, Any], action: str) -> str:
     return STORY_TASK_TEMPLATE.format(**kwargs)
 
 
-def get_story_task_completed_body(payload: Dict[str, Any]) -> Optional[str]:
-    action = get_action_with_primary_id(payload)
-
+def get_story_task_completed_body(payload: Dict[str, Any], action: Dict[str, Any]) -> Optional[str]:
     kwargs = {
         "task_description": action["description"],
     }
@@ -297,9 +295,7 @@ def get_story_task_completed_body(payload: Dict[str, Any]) -> Optional[str]:
         return None
 
 
-def get_story_update_epic_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
-
+def get_story_update_epic_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     kwargs = {
         "story_name_template": STORY_NAME_TEMPLATE.format(
             name=action["name"],
@@ -321,17 +317,15 @@ def get_story_update_epic_body(payload: Dict[str, Any]) -> str:
         return STORY_EPIC_CHANGED_TEMPLATE.format(**kwargs)
     elif new_id:
         kwargs["epic_name_template"] = kwargs["new_epic_name_template"]
-        kwargs["action"] = "added to"
+        kwargs["operation"] = "added to"
     else:
         kwargs["epic_name_template"] = kwargs["old_epic_name_template"]
-        kwargs["action"] = "removed from"
+        kwargs["operation"] = "removed from"
 
     return STORY_ADDED_REMOVED_EPIC_TEMPLATE.format(**kwargs)
 
 
-def get_story_update_estimate_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
-
+def get_story_update_estimate_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     kwargs = {
         "story_name_template": STORY_NAME_TEMPLATE.format(
             name=action["name"],
@@ -357,36 +351,52 @@ def get_reference_by_id(payload: Dict[str, Any], ref_id: int) -> Dict[str, Any]:
     return ref
 
 
-def get_story_create_github_entity_body(payload: Dict[str, Any], entity: str) -> str:
-    action = get_action_with_primary_id(payload)
+def get_secondary_actions_with_param(
+    payload: Dict[str, Any], entity: str, changed_attr: str
+) -> Generator[Dict[str, Any], None, None]:
+    # This function is a generator for secondary actions that have the required changed attributes,
+    # i.e.: "story" that has "pull-request_ids" changed.
+    for action in payload["actions"]:
+        if action["entity_type"] == entity and action["changes"].get(changed_attr) is not None:
+            yield action
 
-    story: Dict[str, Any] = {}
-    for a in payload["actions"]:
-        if a["entity_type"] == "story" and a["changes"].get("workflow_state_id") is not None:
-            story = a
 
-    new_state_id = story["changes"]["workflow_state_id"]["new"]
-    old_state_id = story["changes"]["workflow_state_id"]["old"]
-    new_state = get_reference_by_id(payload, new_state_id)["name"]
-    old_state = get_reference_by_id(payload, old_state_id)["name"]
+def get_story_create_github_entity_body(
+    payload: Dict[str, Any], action: Dict[str, Any], entity: str
+) -> str:
+    pull_request_action: Dict[str, Any] = get_action_with_primary_id(payload)
 
     kwargs = {
-        "name_template": STORY_NAME_TEMPLATE.format(**story),
-        "name": action.get("number") if entity == "pull-request" else action.get("name"),
-        "url": action["url"],
-        "new": new_state,
-        "old": old_state,
+        "name_template": STORY_NAME_TEMPLATE.format(**action),
+        "name": pull_request_action.get("number")
+        if entity == "pull-request" or entity == "pull-request-comment"
+        else pull_request_action.get("name"),
+        "url": pull_request_action["url"],
+        "workflow_state_template": "",
     }
 
-    template = (
-        STORY_GITHUB_PR_TEMPLATE if entity == "pull-request" else STORY_GITHUB_BRANCH_TEMPLATE
-    )
+    # Sometimes the workflow state of the story will not be changed when linking to a PR.
+    if action["changes"].get("workflow_state_id") is not None:
+        new_state_id = action["changes"]["workflow_state_id"]["new"]
+        old_state_id = action["changes"]["workflow_state_id"]["old"]
+        new_state = get_reference_by_id(payload, new_state_id)["name"]
+        old_state = get_reference_by_id(payload, old_state_id)["name"]
+        kwargs["workflow_state_template"] = TRAILING_WORKFLOW_STATE_CHANGE_TEMPLATE.format(
+            new=new_state, old=old_state
+        )
+
+    if entity == "pull-request":
+        template = STORY_GITHUB_PR_TEMPLATE
+    elif entity == "pull-request-comment":
+        template = STORY_GITHUB_COMMENT_PR_TEMPLATE
+    else:
+        template = STORY_GITHUB_BRANCH_TEMPLATE
     return template.format(**kwargs)
 
 
-def get_story_update_attachment_body(payload: Dict[str, Any]) -> Optional[str]:
-    action = get_action_with_primary_id(payload)
-
+def get_story_update_attachment_body(
+    payload: Dict[str, Any], action: Dict[str, Any]
+) -> Optional[str]:
     kwargs = {
         "name_template": STORY_NAME_TEMPLATE.format(
             name=action["name"],
@@ -410,9 +420,27 @@ def get_story_update_attachment_body(payload: Dict[str, Any]) -> Optional[str]:
     return FILE_ATTACHMENT_TEMPLATE.format(**kwargs)
 
 
-def get_story_label_body(payload: Dict[str, Any]) -> Optional[str]:
-    action = get_action_with_primary_id(payload)
+def get_story_joined_label_list(
+    payload: Dict[str, Any], action: Dict[str, Any], label_ids_added: List[int]
+) -> str:
+    labels = []
 
+    for label_id in label_ids_added:
+        label_name = ""
+
+        for action in payload["actions"]:
+            if action.get("id") == label_id:
+                label_name = action.get("name", "")
+
+        if label_name == "":
+            label_name = get_reference_by_id(payload, label_id).get("name", "")
+
+        labels.append(LABEL_TEMPLATE.format(name=label_name))
+
+    return ", ".join(labels)
+
+
+def get_story_label_body(payload: Dict[str, Any], action: Dict[str, Any]) -> Optional[str]:
     kwargs = {
         "name_template": STORY_NAME_TEMPLATE.format(
             name=action["name"],
@@ -421,29 +449,20 @@ def get_story_label_body(payload: Dict[str, Any]) -> Optional[str]:
     }
     label_ids_added = action["changes"]["label_ids"].get("adds")
 
-    # If this is a payload for when a label is removed, ignore it
+    # If this is a payload for when no label is added, ignore it
     if not label_ids_added:
         return None
 
-    label_id = label_ids_added[0]
-
-    label_name = ""
-    for action in payload["actions"]:
-        if action["id"] == label_id:
-            label_name = action.get("name", "")
-
-    if not label_name:
-        for reference in payload["references"]:
-            if reference["id"] == label_id:
-                label_name = reference.get("name", "")
+    kwargs.update(labels=get_story_joined_label_list(payload, action, label_ids_added))
 
-    kwargs.update(label_name=label_name)
-
-    return STORY_LABEL_TEMPLATE.format(**kwargs)
+    return (
+        STORY_LABEL_TEMPLATE.format(**kwargs)
+        if len(label_ids_added) == 1
+        else STORY_LABEL_PLURAL_TEMPLATE.format(**kwargs)
+    )
 
 
-def get_story_update_project_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
+def get_story_update_project_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     kwargs = {
         "name_template": STORY_NAME_TEMPLATE.format(
             name=action["name"],
@@ -462,8 +481,7 @@ def get_story_update_project_body(payload: Dict[str, Any]) -> str:
     return STORY_UPDATE_PROJECT_TEMPLATE.format(**kwargs)
 
 
-def get_story_update_type_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
+def get_story_update_type_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     kwargs = {
         "name_template": STORY_NAME_TEMPLATE.format(
             name=action["name"],
@@ -476,8 +494,7 @@ def get_story_update_type_body(payload: Dict[str, Any]) -> str:
     return STORY_UPDATE_TYPE_TEMPLATE.format(**kwargs)
 
 
-def get_story_update_owner_body(payload: Dict[str, Any]) -> str:
-    action = get_action_with_primary_id(payload)
+def get_story_update_owner_body(payload: Dict[str, Any], action: Dict[str, Any]) -> str:
     kwargs = {
         "name_template": STORY_NAME_TEMPLATE.format(
             name=action["name"],
@@ -488,8 +505,104 @@ def get_story_update_owner_body(payload: Dict[str, Any]) -> str:
     return STORY_UPDATE_OWNER_TEMPLATE.format(**kwargs)
 
 
-def get_entity_name(payload: Dict[str, Any], entity: Optional[str] = None) -> Optional[str]:
-    action = get_action_with_primary_id(payload)
+def get_story_update_batch_body(payload: Dict[str, Any], action: Dict[str, Any]) -> Optional[str]:
+    # When the user selects one or more stories with the checkbox, they can perform
+    # a batch update on multiple stories while changing multiple attribtues at the
+    # same time.
+    changes = action["changes"]
+    kwargs = {
+        "name_template": STORY_NAME_TEMPLATE.format(
+            name=action["name"],
+            app_url=action["app_url"],
+        ),
+        "workflow_state_template": "",
+    }
+
+    templates = []
+    last_change = "other"
+
+    move_sub_templates = []
+    if "epic_id" in changes:
+        last_change = "epic"
+        move_sub_templates.append(
+            STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE.format(
+                entity_type="Epic",
+                old=get_reference_by_id(payload, changes["epic_id"].get("old")).get("name"),
+                new=get_reference_by_id(payload, changes["epic_id"].get("new")).get("name"),
+            )
+        )
+    if "project_id" in changes:
+        last_change = "project"
+        move_sub_templates.append(
+            STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE.format(
+                entity_type="Project",
+                old=get_reference_by_id(payload, changes["project_id"].get("old")).get("name"),
+                new=get_reference_by_id(payload, changes["project_id"].get("new")).get("name"),
+            )
+        )
+    if len(move_sub_templates) > 0:
+        templates.append(
+            STORY_UPDATE_BATCH_CHANGED_TEMPLATE.format(
+                operation="was moved",
+                sub_templates=", ".join(move_sub_templates),
+            )
+        )
+
+    if "story_type" in changes:
+        last_change = "type"
+        templates.append(
+            STORY_UPDATE_BATCH_CHANGED_TEMPLATE.format(
+                operation="{} changed".format("was" if len(templates) == 0 else "and"),
+                sub_templates=STORY_UPDATE_BATCH_CHANGED_SUB_TEMPLATE.format(
+                    entity_type="type",
+                    old=changes["story_type"].get("old"),
+                    new=changes["story_type"].get("new"),
+                ),
+            )
+        )
+
+    if "label_ids" in changes:
+        last_change = "label"
+        labels = get_story_joined_label_list(payload, action, changes["label_ids"].get("adds"))
+        templates.append(
+            STORY_UPDATE_BATCH_ADD_REMOVE_TEMPLATE.format(
+                operation="{} added".format("was" if len(templates) == 0 else "and"),
+                entity="the new label{plural} {labels}".format(
+                    plural="s" if len(changes["label_ids"]) > 1 else "", labels=labels
+                ),
+            )
+        )
+
+    if "workflow_state_id" in changes:
+        last_change = "state"
+        kwargs.update(
+            workflow_state_template=TRAILING_WORKFLOW_STATE_CHANGE_TEMPLATE.format(
+                old=get_reference_by_id(payload, changes["workflow_state_id"].get("old")).get(
+                    "name"
+                ),
+                new=get_reference_by_id(payload, changes["workflow_state_id"].get("new")).get(
+                    "name"
+                ),
+            )
+        )
+
+    # Use the default template for state change if it is the only one change.
+    if len(templates) <= 1 or (len(templates) == 0 and last_change == "state"):
+        event: str = "{}_{}".format("story_update", last_change)
+        alternative_body_func = EVENT_BODY_FUNCTION_MAPPER.get(event)
+        # If last_change is not one of "epic", "project", "type", "label" and "state"
+        # we should ignore the action as there is no way for us to render the changes.
+        if alternative_body_func is None:
+            return None
+        return alternative_body_func(payload, action)
+
+    kwargs.update(templates=", ".join(templates))
+    return STORY_UPDATE_BATCH_TEMPLATE.format(**kwargs)
+
+
+def get_entity_name(
+    payload: Dict[str, Any], action: Dict[str, Any], entity: Optional[str] = None
+) -> Optional[str]:
     name = action.get("name")
 
     if name is None or action["entity_type"] == "branch":
@@ -511,16 +624,38 @@ def get_name_template(entity: str) -> str:
     return EPIC_NAME_TEMPLATE
 
 
-EVENT_BODY_FUNCTION_MAPPER: Dict[str, Callable[[Dict[str, Any]], Optional[str]]] = {
+def send_stream_messages_for_actions(
+    request: HttpRequest,
+    user_profile: UserProfile,
+    payload: Dict[str, Any],
+    action: Dict[str, Any],
+    event: str,
+) -> None:
+    body_func = EVENT_BODY_FUNCTION_MAPPER.get(event)
+    topic_func = get_topic_function_based_on_type(payload, action)
+    if body_func is None or topic_func is None:
+        raise UnsupportedWebhookEventType(event)
+
+    topic = topic_func(payload, action)
+    body = body_func(payload, action)
+
+    if topic and body:
+        check_send_webhook_message(request, user_profile, topic, body)
+
+
+EVENT_BODY_FUNCTION_MAPPER: Dict[str, Callable[[Dict[str, Any], Dict[str, Any]], Optional[str]]] = {
     "story_update_archived": partial(get_update_archived_body, entity="story"),
     "epic_update_archived": partial(get_update_archived_body, entity="epic"),
     "story_create": get_story_create_body,
     "pull-request_create": partial(get_story_create_github_entity_body, entity="pull-request"),
+    "pull-request_comment": partial(
+        get_story_create_github_entity_body, entity="pull-request-comment"
+    ),
     "branch_create": partial(get_story_create_github_entity_body, entity="branch"),
     "story_delete": get_delete_body,
     "epic_delete": get_delete_body,
-    "story-task_create": partial(get_story_task_body, action="added to"),
-    "story-task_delete": partial(get_story_task_body, action="removed from"),
+    "story-task_create": partial(get_story_task_body, operation="added to"),
+    "story-task_delete": partial(get_story_task_body, operation="removed from"),
     "story-task_update_complete": get_story_task_completed_body,
     "story_update_epic": get_story_update_epic_body,
     "story_update_estimate": get_story_update_estimate_body,
@@ -538,6 +673,7 @@ def get_name_template(entity: str) -> str:
     "story_update_state": get_story_update_state_body,
     "epic_update_name": partial(get_update_name_body, entity="epic"),
     "story_update_name": partial(get_update_name_body, entity="story"),
+    "story_update_batch": get_story_update_batch_body,
 }
 
 EVENT_TOPIC_FUNCTION_MAPPER = {
@@ -554,6 +690,20 @@ def get_name_template(entity: str) -> str:
     "story-comment_update",
 }
 
+EVENTS_SECONDARY_ACTIONS_FUNCTION_MAPPER: Dict[
+    str, Callable[[Dict[str, Any]], Generator[Dict[str, Any], None, None]]
+] = {
+    "pull-request_create": partial(
+        get_secondary_actions_with_param, entity="story", changed_attr="pull_request_ids"
+    ),
+    "branch_create": partial(
+        get_secondary_actions_with_param, entity="story", changed_attr="branch_ids"
+    ),
+    "pull-request_comment": partial(
+        get_secondary_actions_with_param, entity="story", changed_attr="pull_request_ids"
+    ),
+}
+
 
 @webhook_view("ClubHouse")
 @has_request_variables
@@ -570,18 +720,22 @@ def api_clubhouse_webhook(
     if payload is None:
         return json_success()
 
-    event = get_event(payload)
-    if event is None:
-        return json_success()
-
-    body_func = EVENT_BODY_FUNCTION_MAPPER.get(event)
-    topic_func = get_topic_function_based_on_type(payload)
-    if body_func is None or topic_func is None:
-        raise UnsupportedWebhookEventType(event)
-    topic = topic_func(payload)
-    body = body_func(payload)
-
-    if topic and body:
-        check_send_webhook_message(request, user_profile, topic, body)
+    if payload.get("primary_id") is not None:
+        action = get_action_with_primary_id(payload)
+        primary_actions = [action]
+    else:
+        primary_actions = payload["actions"]
+
+    for primary_action in primary_actions:
+        event = get_event(payload, primary_action)
+        if event is None:
+            continue
+
+        if event in EVENTS_SECONDARY_ACTIONS_FUNCTION_MAPPER:
+            sec_actions_func = EVENTS_SECONDARY_ACTIONS_FUNCTION_MAPPER[event]
+            for sec_action in sec_actions_func(payload):
+                send_stream_messages_for_actions(request, user_profile, payload, sec_action, event)
+        else:
+            send_stream_messages_for_actions(request, user_profile, payload, primary_action, event)
 
     return json_success()


Test patch:
diff --git a/zerver/webhooks/clubhouse/tests.py b/zerver/webhooks/clubhouse/tests.py
--- a/zerver/webhooks/clubhouse/tests.py
+++ b/zerver/webhooks/clubhouse/tests.py
@@ -1,5 +1,5 @@
 import json
-from unittest.mock import MagicMock, patch
+from unittest.mock import MagicMock, call, patch
 
 from zerver.lib.test_classes import WebhookTestCase
 
@@ -121,7 +121,7 @@ def test_story_task_completed(self) -> None:
         expected_message = "Task **A new task for this story** ([Add cool feature!](https://app.clubhouse.io/zulip/story/11)) was completed. :tada:"
         self.check_webhook("story_task_complete", "Add cool feature!", expected_message)
 
-    @patch("zerver.lib.webhooks.common.check_send_webhook_message")
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
     def test_story_task_incomplete_ignore(self, check_send_webhook_message_mock: MagicMock) -> None:
         payload = self.get_body("story_task_not_complete")
         result = self.client_post(self.url, payload, content_type="application/json")
@@ -159,7 +159,7 @@ def test_story_file_attachment_added(self) -> None:
         expected_message = "A file attachment `zuliprc` was added to the story [Add cool feature!](https://app.clubhouse.io/zulip/story/11)."
         self.check_webhook("story_update_add_attachment", "Add cool feature!", expected_message)
 
-    @patch("zerver.lib.webhooks.common.check_send_webhook_message")
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
     def test_story_file_attachment_removed_ignore(
         self, check_send_webhook_message_mock: MagicMock
     ) -> None:
@@ -172,13 +172,17 @@ def test_story_label_added(self) -> None:
         expected_message = "The label **mockup** was added to the story [An epic story!](https://app.clubhouse.io/zulip/story/23)."
         self.check_webhook("story_update_add_label", "An epic story!", expected_message)
 
+    def test_story_label_multiple_added(self) -> None:
+        expected_message = "The labels **mockup**, **label** were added to the story [An epic story!](https://app.clubhouse.io/zulip/story/23)."
+        self.check_webhook("story_update_add_multiple_labels", "An epic story!", expected_message)
+
     def test_story_label_added_label_name_in_actions(self) -> None:
         expected_message = "The label **sad** was added to the story [An emotional story!](https://app.clubhouse.io/zulip/story/28)."
         self.check_webhook(
             "story_update_add_label_name_in_action", "An emotional story!", expected_message
         )
 
-    @patch("zerver.lib.webhooks.common.check_send_webhook_message")
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
     def test_story_label_removed_ignore(self, check_send_webhook_message_mock: MagicMock) -> None:
         payload = self.get_body("story_update_remove_label")
         result = self.client_post(self.url, payload, content_type="application/json")
@@ -201,13 +205,180 @@ def test_story_update_add_github_pull_request(self) -> None:
             expected_message,
         )
 
+    def test_story_update_add_github_pull_request_without_workflow_state(self) -> None:
+        expected_message = "New GitHub PR [#10](https://github.com/eeshangarg/Scheduler/pull/10) opened for story [Testing pull requests with Story](https://app.clubhouse.io/zulip/story/28)."
+        self.check_webhook(
+            "story_update_add_github_pull_request_without_workflow_state",
+            "Testing pull requests with Story",
+            expected_message,
+        )
+
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
+    def test_story_update_add_github_multiple_pull_requests(
+        self, check_send_webhook_message_mock: MagicMock
+    ) -> None:
+        payload = self.get_body("story_update_add_github_multiple_pull_requests")
+        self.client_post(self.url, payload, content_type="application/json")
+        expected_message = "New GitHub PR [#2](https://github.com/PIG208/test-clubhouse/pull/2) opened for story [{name}]({url}) (Unscheduled -> In Development)."
+        request, user_profile = (
+            check_send_webhook_message_mock.call_args_list[0][0][0],
+            check_send_webhook_message_mock.call_args_list[0][0][1],
+        )
+        expected_list = [
+            call(
+                request,
+                user_profile,
+                "Story1",
+                expected_message.format(
+                    name="Story1", url="https://app.clubhouse.io/pig208/story/17"
+                ),
+            ),
+            call(
+                request,
+                user_profile,
+                "Story2",
+                expected_message.format(
+                    name="Story2", url="https://app.clubhouse.io/pig208/story/18"
+                ),
+            ),
+        ]
+        self.assertEqual(check_send_webhook_message_mock.call_args_list, expected_list)
+
+    def test_story_update_add_github_pull_request_with_comment(self) -> None:
+        expected_message = "Existing GitHub PR [#2](https://github.com/PIG208/test-clubhouse/pull/2) associated with story [asd2](https://app.clubhouse.io/pig208/story/15)."
+        self.check_webhook(
+            "story_update_add_github_pull_request_with_comment",
+            "asd2",
+            expected_message,
+        )
+
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
+    def test_story_update_add_github_multiple_pull_requests_with_comment(
+        self, check_send_webhook_message_mock: MagicMock
+    ) -> None:
+        payload = self.get_body("story_update_add_github_multiple_pull_requests_with_comment")
+        self.client_post(self.url, payload, content_type="application/json")
+        expected_message = "Existing GitHub PR [#1](https://github.com/PIG208/test-clubhouse/pull/1) associated with story [{name}]({url}) (Unscheduled -> In Development)."
+        request, user_profile = (
+            check_send_webhook_message_mock.call_args_list[0][0][0],
+            check_send_webhook_message_mock.call_args_list[0][0][1],
+        )
+        expected_list = [
+            call(
+                request,
+                user_profile,
+                "new1",
+                expected_message.format(
+                    name="new1", url="https://app.clubhouse.io/pig208/story/26"
+                ),
+            ),
+            call(
+                request,
+                user_profile,
+                "new2",
+                expected_message.format(
+                    name="new2", url="https://app.clubhouse.io/pig208/story/27"
+                ),
+            ),
+        ]
+        self.assertEqual(check_send_webhook_message_mock.call_args_list, expected_list)
+
     def test_story_update_add_github_branch(self) -> None:
         expected_message = "New GitHub branch [eeshangarg/ch27/testing-pull-requests-with-story](https://github.com/eeshangarg/scheduler/tree/eeshangarg/ch27/testing-pull-requests-with-story) associated with story [Testing pull requests with Story](https://app.clubhouse.io/zulip/story/27) (Unscheduled -> In Development)."
         self.check_webhook(
             "story_update_add_github_branch", "Testing pull requests with Story", expected_message
         )
 
-    @patch("zerver.lib.webhooks.common.check_send_webhook_message")
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
+    def test_story_update_batch(self, check_send_webhook_message_mock: MagicMock) -> None:
+        payload = self.get_body("story_update_everything_at_once")
+        self.client_post(self.url, payload, content_type="application/json")
+        expected_message = "The story [{name}]({url}) was moved from Epic **epic** to **testeipc**, Project **Product Development** to **test2**, and changed from type **feature** to **bug**, and added with the new label **low priority** (In Development -> Ready for Review)."
+        request, user_profile = (
+            check_send_webhook_message_mock.call_args_list[0][0][0],
+            check_send_webhook_message_mock.call_args_list[0][0][1],
+        )
+        expected_list = [
+            call(
+                request,
+                user_profile,
+                "asd4",
+                expected_message.format(
+                    name="asd4", url="https://app.clubhouse.io/pig208/story/17"
+                ),
+            ),
+            call(
+                request,
+                user_profile,
+                "new1",
+                expected_message.format(
+                    name="new1", url="https://app.clubhouse.io/pig208/story/26"
+                ),
+            ),
+            call(
+                request,
+                user_profile,
+                "new2",
+                expected_message.format(
+                    name="new2", url="https://app.clubhouse.io/pig208/story/27"
+                ),
+            ),
+        ]
+        self.assertEqual(check_send_webhook_message_mock.call_args_list, expected_list)
+
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
+    def test_story_update_batch_each_with_one_change(
+        self, check_send_webhook_message_mock: MagicMock
+    ) -> None:
+        payload = self.get_body("story_update_multiple_at_once")
+        self.client_post(self.url, payload, content_type="application/json")
+        expected_messages = [
+            (
+                "asd4",
+                "The type of the story [asd4](https://app.clubhouse.io/pig208/story/17) was changed from **feature** to **bug**.",
+            ),
+            (
+                "new1",
+                "The story [new1](https://app.clubhouse.io/pig208/story/26) was moved from **epic** to **testeipc**.",
+            ),
+            (
+                "new2",
+                "The label **low priority** was added to the story [new2](https://app.clubhouse.io/pig208/story/27).",
+            ),
+            (
+                "new3",
+                "State of the story [new3](https://app.clubhouse.io/pig208/story/28) was changed from **In Development** to **Ready for Review**.",
+            ),
+            (
+                "new4",
+                "The story [new4](https://app.clubhouse.io/pig208/story/29) was moved from the **Product Development** project to **test2**.",
+            ),
+        ]
+        request, user_profile = (
+            check_send_webhook_message_mock.call_args_list[0][0][0],
+            check_send_webhook_message_mock.call_args_list[0][0][1],
+        )
+        expected_list = [
+            call(
+                request,
+                user_profile,
+                expected_message[0],
+                expected_message[1],
+            )
+            for expected_message in expected_messages
+        ]
+        self.assertEqual(check_send_webhook_message_mock.call_args_list, expected_list)
+
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
+    def test_story_update_batch_not_supported_ignore(
+        self, check_send_webhook_message_mock: MagicMock
+    ) -> None:
+        payload = self.get_body("story_update_multiple_not_supported")
+        result = self.client_post(self.url, payload, content_type="application/json")
+        self.assertFalse(check_send_webhook_message_mock.called)
+        self.assert_json_success(result)
+
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
     def test_empty_post_request_body_ignore(
         self, check_send_webhook_message_mock: MagicMock
     ) -> None:
@@ -216,7 +387,7 @@ def test_empty_post_request_body_ignore(
         self.assertFalse(check_send_webhook_message_mock.called)
         self.assert_json_success(result)
 
-    @patch("zerver.lib.webhooks.common.check_send_webhook_message")
+    @patch("zerver.webhooks.clubhouse.view.check_send_webhook_message")
     def test_story_comment_updated_ignore(self, check_send_webhook_message_mock: MagicMock) -> None:
         payload = self.get_body("story_comment_updated")
         result = self.client_post(self.url, payload, content_type="application/json")